Files
fission-console/terraform/provider/internal/resources/package_resource.go
T
Naeel 01df1498d6 feat: big-suite terraform example + provider nodejs zip fix
- examples/big-suite: E-Commerce 10 functions, 6 layers, real depends_on
- provider: nodejs packages now wrapped in ESM zip (buildJSDeployZip)
- provider: loadPackageLiteral reads raw file, nodejs zip via source_dir
- all 10 functions verified working: python/node/ruby/go runtimes
2026-04-20 11:38:45 +03:00

590 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package resources
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"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/resource/schema/stringdefault"
"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{}
var _ resource.ResourceWithModifyPlan = &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"`
DeployType types.String `tfsdk:"deploy_type"`
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: "Путь к директории с кодом (поддерживаются main.py, main.js, main.go, main.php, main.rb, main.pl).",
},
"code_path": schema.StringAttribute{
Optional: true,
Description: "Путь к готовому файлу исходника для literal deployment.",
},
"code_hash": schema.StringAttribute{
Optional: true,
Computed: true,
Description: "Произвольный хеш кода для контроля изменений.",
},
"build_command": schema.StringAttribute{
Optional: true,
Description: "Команда сборки пакета в Fission.",
},
"deploy_type": schema.StringAttribute{
Optional: true,
Computed: true,
Default: stringdefault.StaticString("literal"),
Description: "Тип деплоя: 'literal' (default) — код в deployment.literal, 'source' — код в source.literal (для Go и языков с build step).",
},
"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.",
},
},
}
}
// ModifyPlan пересчитывает code_hash по локальному коду, чтобы terraform видел изменения source_dir/code_path.
func (r *PackageResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
if req.Plan.Raw.IsNull() {
return
}
var plan packageResourceModel
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
if resp.Diagnostics.HasError() {
return
}
var config packageResourceModel
resp.Diagnostics.Append(req.Config.Get(ctx, &config)...)
if resp.Diagnostics.HasError() {
return
}
if hasManualCodeHash(config.CodeHash) {
return
}
if plan.SourceDir.IsUnknown() || plan.CodePath.IsUnknown() {
return
}
if !validatePackageSource(plan.SourceDir, plan.CodePath, &resp.Diagnostics) {
return
}
literalBytes, err := loadPackageContent(plan.SourceDir.ValueString(), plan.CodePath.ValueString(), plan.DeployType.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error())
return
}
plan.CodeHash = types.StringValue(calculateCodeHash(literalBytes))
resp.Diagnostics.Append(resp.Plan.Set(ctx, &plan)...)
}
// 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
}
if err := ensureEnvironmentExists(ctx, r.client, namespace, plan.Environment.ValueString()); err != nil {
resp.Diagnostics.AddError("Ошибка валидации Environment для Package", err.Error())
return
}
literalBytes, err := loadPackageContent(plan.SourceDir.ValueString(), plan.CodePath.ValueString(), plan.DeployType.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error())
return
}
if !hasManualCodeHash(plan.CodeHash) {
plan.CodeHash = types.StringValue(calculateCodeHash(literalBytes))
}
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
}
if err := ensureEnvironmentExists(ctx, r.client, namespace, plan.Environment.ValueString()); err != nil {
resp.Diagnostics.AddError("Ошибка валидации Environment для Package", err.Error())
return
}
literalBytes, err := loadPackageContent(plan.SourceDir.ValueString(), plan.CodePath.ValueString(), plan.DeployType.ValueString())
if err != nil {
resp.Diagnostics.AddError("Ошибка чтения исходного кода пакета", err.Error())
return
}
if !hasManualCodeHash(plan.CodeHash) {
plan.CodeHash = types.StringValue(calculateCodeHash(literalBytes))
}
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 для literal deployment (один файл).
func loadPackageLiteral(sourceDir, codePath string) ([]byte, error) {
if sourceDir != "" {
mainFilePath, err := resolveMainSourceFile(sourceDir)
if err != nil {
return nil, err
}
mainFileBytes, err := os.ReadFile(mainFilePath)
if err != nil {
return nil, fmt.Errorf("read source_dir source file %q: %w", mainFilePath, err)
}
// Node.js runtime ожидает ZIP с package.json + main.js (ESM wrapper).
if filepath.Ext(mainFilePath) == ".js" {
return buildJSDeployZip(string(mainFileBytes))
}
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
}
// buildJSDeployZip wraps Node.js user code into a ZIP with package.json (ESM) + main.js wrapper.
// This matches the format expected by ghcr.io/fission/node-env runtime.
func buildJSDeployZip(code string) ([]byte, error) {
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
pkgfw, err := zw.Create("package.json")
if err != nil {
return nil, err
}
if _, err := pkgfw.Write([]byte(`{"type":"module"}`)); err != nil {
return nil, err
}
codeJSON, err := json.Marshal(code)
if err != nil {
return nil, fmt.Errorf("marshal user code: %w", err)
}
wrapper := fmt.Sprintf(`const __mod = { exports: {} };
(new Function('module', 'exports', %s))(__mod, __mod.exports);
const _fn = __mod.exports;
export default async function(ctx) {
const fn = typeof _fn === 'function' ? _fn : (_fn.default || _fn.handler || _fn.main);
if (!fn) throw new Error('no exported function found in user code');
const result = await fn(ctx);
if (!result) return { status: 200, body: '' };
if (typeof result.status !== 'undefined') return result;
return { status: 200, ...result };
}
`, string(codeJSON))
fw, err := zw.Create("main.js")
if err != nil {
return nil, err
}
if _, err := fw.Write([]byte(wrapper)); err != nil {
return nil, err
}
if err := zw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// loadPackageSourceArchive создает zip-архив из source_dir для builder pipeline.
func loadPackageSourceArchive(sourceDir string) ([]byte, error) {
var buf bytes.Buffer
zipWriter := zip.NewWriter(&buf)
err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
relPath, err := filepath.Rel(sourceDir, path)
if err != nil {
return fmt.Errorf("compute relative path for %q: %w", path, err)
}
writer, err := zipWriter.Create(relPath)
if err != nil {
return fmt.Errorf("create zip entry %q: %w", relPath, err)
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("open file %q: %w", path, err)
}
defer file.Close()
_, err = io.Copy(writer, file)
return err
})
if err != nil {
return nil, fmt.Errorf("zip source_dir %q: %w", sourceDir, err)
}
if err := zipWriter.Close(); err != nil {
return nil, fmt.Errorf("close zip writer: %w", err)
}
return buf.Bytes(), nil
}
// loadPackageContent загружает содержимое пакета в зависимости от deploy_type.
func loadPackageContent(sourceDir, codePath, deployType string) ([]byte, error) {
if deployType == "source" && sourceDir != "" {
return loadPackageSourceArchive(sourceDir)
}
return loadPackageLiteral(sourceDir, codePath)
}
// resolveMainSourceFile выбирает основной файл исходника из source_dir.
func resolveMainSourceFile(sourceDir string) (string, error) {
candidates := []string{"main.py", "main.js", "main.go", "main.php", "main.rb", "main.pl"}
for _, candidate := range candidates {
candidatePath := filepath.Join(sourceDir, candidate)
fileInfo, err := os.Stat(candidatePath)
if err == nil && !fileInfo.IsDir() {
return candidatePath, nil
}
}
return "", fmt.Errorf("source_dir %q must contain one of: main.py, main.js, main.go, main.php, main.rb, main.pl", sourceDir)
}
// packageToUnstructured преобразует Terraform model в Kubernetes CRD payload.
func packageToUnstructured(model packageResourceModel, namespace string, literalBytes []byte) *unstructured.Unstructured {
literalSource := base64.StdEncoding.EncodeToString(literalBytes)
deployType := "literal"
if !model.DeployType.IsNull() && !model.DeployType.IsUnknown() && model.DeployType.ValueString() != "" {
deployType = model.DeployType.ValueString()
}
spec := map[string]interface{}{
"environment": map[string]interface{}{
"name": model.Environment.ValueString(),
"namespace": namespace,
},
}
if deployType == "source" {
// Source mode: код в spec.source (для builder pipeline — Go и др.)
spec["source"] = map[string]interface{}{
"type": "literal",
"literal": literalSource,
}
} else {
// Literal/deployment mode: код в spec.deployment (Python, Node, PHP, Ruby, Perl)
spec["deployment"] = map[string]interface{}{
"type": "literal",
"literal": literalSource,
}
}
if buildCommand := model.BuildCmd.ValueString(); buildCommand != "" {
spec["buildcmd"] = buildCommand
}
object := map[string]interface{}{
"apiVersion": "fission.io/v1",
"kind": "Package",
"metadata": map[string]interface{}{
"name": model.Name.ValueString(),
"namespace": namespace,
},
"spec": spec,
}
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")
deploymentLiteral, _, _ := unstructured.NestedString(packageObject.Object, "spec", "deployment", "literal")
sourceLiteral, _, _ := unstructured.NestedString(packageObject.Object, "spec", "source", "literal")
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,
DeployType: base.DeployType,
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)
}
// Определить hash из содержимого (deployment или source)
literalForHash := deploymentLiteral
if literalForHash == "" {
literalForHash = sourceLiteral
}
if literalForHash != "" {
if literalBytes, err := base64.StdEncoding.DecodeString(literalForHash); err == nil {
state.CodeHash = types.StringValue(calculateCodeHash(literalBytes))
}
}
return state
}
func hasManualCodeHash(codeHash types.String) bool {
return !codeHash.IsNull() && !codeHash.IsUnknown() && codeHash.ValueString() != ""
}
func calculateCodeHash(literalBytes []byte) string {
sum := sha256.Sum256(literalBytes)
return fmt.Sprintf("%x", sum)
}