From 5f913e9f250698d99bd19a1928287dfaba0f1272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Mon, 9 Mar 2026 17:45:45 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20provider=20v0.1.10=20=E2=80=94=20source?= =?UTF-8?q?=5Fdir=20(zip=20=D0=B2=D0=BD=D1=83=D1=82=D1=80=D0=B8=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=D0=B0),=20?= =?UTF-8?q?=D1=83=D0=B1=D1=80=D0=B0=D0=BD=20archive=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=B9=D0=B4=D0=B5=D1=80=20=D0=B8=D0=B7=20=D0=B2?= =?UTF-8?q?=D1=81=D0=B5=D1=85=20=D0=BF=D1=80=D0=B8=D0=BC=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- examples/README.md | 14 +-- examples/hello-node/http.tf | 19 +-- examples/hello-node/job.tf | 22 +--- examples/hello-node/main.tf | 6 +- examples/notes-python/main.tf | 7 +- examples/notes-python/notes-list.tf | 23 +--- examples/notes-python/notes.tf | 27 +--- examples/notes-python/sql-runner.tf | 15 +-- examples/simple-node/main.tf | 6 +- examples/simple-node/time-display.tf | 12 +- examples/simple-node/time-getter.tf | 12 +- examples/simple-python/main.tf | 6 +- examples/simple-python/time-display.tf | 12 +- examples/simple-python/time-getter.tf | 12 +- terraform/provider/internal/client/client.go | 9 +- .../internal/resources/function_resource.go | 116 ++++++++++++++++-- 16 files changed, 148 insertions(+), 170 deletions(-) diff --git a/examples/README.md b/examples/README.md index c4ff06e..5bf4e0e 100644 --- a/examples/README.md +++ b/examples/README.md @@ -34,7 +34,7 @@ terraform apply -auto-approve terraform output job_result # Проверить функцию: -curl -s $(terraform output -raw display_url) +curl -s https://sless-api.kube5s.ru/fn/default/simple-py-time-display ``` --- @@ -47,7 +47,7 @@ terraform init terraform apply -auto-approve terraform output job_result -curl -s $(terraform output -raw display_url) +curl -s https://sless-api.kube5s.ru/fn/default/simple-node-time-display ``` --- @@ -62,7 +62,7 @@ terraform init terraform apply -auto-approve # Проверить HTTP-функцию: -curl -s -X POST $(terraform output -raw trigger_url) \ +curl -s -X POST https://sless-api.kube5s.ru/fn/default/hello-http \ -H 'Content-Type: application/json' -d '{"name":"World"}' # Посмотреть результат джоба: @@ -99,16 +99,16 @@ terraform output notes_url # CRUD terraform output notes_list_url # список всех записей # Создать запись: -curl -s -X POST "$(terraform output -raw notes_url)/add?title=Hello&body=World" +curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/add?title=Hello&body=World" # Список записей: -curl -s $(terraform output -raw notes_list_url) +curl -s https://sless-api.kube5s.ru/fn/default/notes-list # Обновить (id из предыдущего ответа): -curl -s -X POST "$(terraform output -raw notes_url)/update?id=1&title=Updated&body=New+body" +curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/update?id=1&title=Updated&body=New+body" # Удалить: -curl -s -X POST "$(terraform output -raw notes_url)/delete?id=1" +curl -s -X POST "https://sless-api.kube5s.ru/fn/default/notes/delete?id=1" ``` --- diff --git a/examples/hello-node/http.tf b/examples/hello-node/http.tf index 76c92d1..beee105 100644 --- a/examples/hello-node/http.tf +++ b/examples/hello-node/http.tf @@ -1,17 +1,6 @@ -# 2026-03-08 +# 2026-03-08 / Изменено: 2026-03-09 # http.tf — HTTP-функция: принимает запросы, возвращает приветствие. # Код: code/handler-http.js -# -# Использование: -# terraform apply -# curl -s -X POST $(terraform output -raw trigger_url) \ -# -H 'Content-Type: application/json' -d '{"name":"Naeel"}' - -data "archive_file" "handler_http" { - type = "zip" - source_file = "${path.module}/code/handler-http.js" - output_path = "${path.module}/dist/handler-http.zip" -} resource "sless_function" "hello_http" { name = "hello-http" @@ -20,17 +9,13 @@ resource "sless_function" "hello_http" { memory_mb = 128 timeout_sec = 30 - code_path = data.archive_file.handler_http.output_path - # filesha256 исходного файла — надёжнее чем output_md5 zip: - # MD5 zip зависит от метаданных архива и может совпасть при разном содержимом - code_hash = filesha256("${path.module}/code/handler-http.js") + source_dir = "${path.module}/code" } resource "sless_trigger" "hello_http" { name = "hello-http-trigger" type = "http" function = sless_function.hello_http.name - # enabled = false — чтобы заморозить функцию (не удаляя ресурс), потом поставить зновь true enabled = true } diff --git a/examples/hello-node/job.tf b/examples/hello-node/job.tf index 0c8fc5a..843d02f 100644 --- a/examples/hello-node/job.tf +++ b/examples/hello-node/job.tf @@ -1,19 +1,6 @@ -# 2026-03-08 +# 2026-03-08 / Изменено: 2026-03-09 # job.tf — одноразовая функция: суммирует числа из переданного массива. # Код: code/handler-job.js -# -# Использование: -# terraform apply -# # apply блокируется до завершения джоба (~2 мин kaniko + выполнение) -# terraform output job_message -# -# Повторный запуск — изменить event_json и снова terraform apply. - -data "archive_file" "handler_job" { - type = "zip" - source_file = "${path.module}/code/handler-job.js" - output_path = "${path.module}/dist/handler-job.zip" -} resource "sless_function" "hello_job" { name = "hello-job" @@ -22,13 +9,10 @@ resource "sless_function" "hello_job" { memory_mb = 128 timeout_sec = 30 - code_path = data.archive_file.handler_job.output_path - # filesha256 исходного файла — надёжнее чем output_md5 zip - code_hash = filesha256("${path.module}/code/handler-job.js") + source_dir = "${path.module}/code" } -# Одноразовый запуск. Все поля immutable — изменение любого пересоздаёт джоб. -# run_id: 0 = не запускать, 1+ = запустить. Для повторного запуска увеличь run_id (1→2→3...). +# Одноразовый запуск. Для повторного запуска увеличь run_id (1→2→3...). resource "sless_job" "hello_run" { name = "hello-run" function = sless_function.hello_job.name diff --git a/examples/hello-node/main.tf b/examples/hello-node/main.tf index d5843b9..cfb00b4 100644 --- a/examples/hello-node/main.tf +++ b/examples/hello-node/main.tf @@ -8,11 +8,7 @@ terraform { required_providers { sless = { source = "terra.k8c.ru/naeel/sless" - version = "~> 0.1.9" - } - archive = { - source = "hashicorp/archive" - version = "~> 2.0" + version = "~> 0.1.10" } } } diff --git a/examples/notes-python/main.tf b/examples/notes-python/main.tf index 82d0415..505e104 100644 --- a/examples/notes-python/main.tf +++ b/examples/notes-python/main.tf @@ -14,12 +14,7 @@ terraform { # Провайдер для управления serverless функциями через sless API sless = { source = "terra.k8c.ru/naeel/sless" - version = "~> 0.1.9" - } - # hashicorp/archive — для упаковки исходников в zip перед загрузкой - archive = { - source = "hashicorp/archive" - version = "~> 2.0" + version = "~> 0.1.10" } } } diff --git a/examples/notes-python/notes-list.tf b/examples/notes-python/notes-list.tf index 1b76e13..ced284b 100644 --- a/examples/notes-python/notes-list.tf +++ b/examples/notes-python/notes-list.tf @@ -1,22 +1,6 @@ # 2026-03-09 -# notes-list.tf — функция для чтения всех заметок одним запросом. -# -# Отдельная от CRUD функция — «read-only» эндпоинт без роутинга. -# Принимает GET или POST, параметры игнорирует. -# Возвращает JSON-массив всех записей, сортировка: новые первые. -# -# Пример запроса: -# curl https://sless-api.kube5s.ru/fn/default/notes-list +# notes-list.tf — read-only эндпоинт: возвращает все заметки, сортировка новые первые. -# Упаковка исходников notes_list.py в zip. -data "archive_file" "notes_list_zip" { - type = "zip" - source_dir = "${path.module}/code/notes-list" - output_path = "${path.module}/dist/notes-list.zip" -} - -# Read-only функция в кластере. -# entrypoint = "notes_list.list_notes" → файл notes_list.py, функция list_notes(). resource "sless_function" "notes_list" { name = "notes-list" runtime = "python3.11" @@ -28,12 +12,9 @@ resource "sless_function" "notes_list" { PG_DSN = var.pg_dsn } - code_path = data.archive_file.notes_list_zip.output_path - code_hash = filesha256("${path.module}/code/notes-list/notes_list.py") + source_dir = "${path.module}/code/notes-list" } -# HTTP-триггер для read-only функции. -# Создаёт Ingress, URL доступен в outputs.tf. resource "sless_trigger" "notes_list_http" { name = "notes-list-http" type = "http" diff --git a/examples/notes-python/notes.tf b/examples/notes-python/notes.tf index d7f2b8e..7830dc3 100644 --- a/examples/notes-python/notes.tf +++ b/examples/notes-python/notes.tf @@ -1,24 +1,11 @@ # 2026-03-09 # notes.tf — CRUD функция для управления заметками (CREATE / UPDATE / DELETE). # -# Одна функция обрабатывает все операции — роутинг по sub-path URL. -# Sub-path и query string пробрасывает прокси (invoke.go) → runtime добавляет -# их в event как _path и _query. -# -# Маршруты (все методы принимаются, рекомендуется POST): -# /fn/default/notes/add?title=...&body=... → INSERT, возвращает запись -# /fn/default/notes/update?id=1&title=...&body=... → UPDATE, возвращает запись -# /fn/default/notes/delete?id=1 → DELETE, возвращает {deleted: id} +# Маршруты (рекомендуется POST): +# /fn/default/notes/add?title=...&body=... → INSERT +# /fn/default/notes/update?id=1&title=...&body=... → UPDATE +# /fn/default/notes/delete?id=1 → DELETE -# Упаковка исходников notes_crud.py в zip. -data "archive_file" "notes_crud_zip" { - type = "zip" - source_dir = "${path.module}/code/notes" - output_path = "${path.module}/dist/notes.zip" -} - -# CRUD функция в кластере. -# entrypoint = "notes_crud.crud" → файл notes_crud.py, функция crud(). resource "sless_function" "notes_crud" { name = "notes" runtime = "python3.11" @@ -30,13 +17,9 @@ resource "sless_function" "notes_crud" { PG_DSN = var.pg_dsn } - code_path = data.archive_file.notes_crud_zip.output_path - code_hash = filesha256("${path.module}/code/notes/notes_crud.py") + source_dir = "${path.module}/code/notes" } -# HTTP-триггер для CRUD функции. -# Создаёт Ingress в кластере, URL доступен в outputs.tf. -# Базовый URL: https://sless-api.kube5s.ru/fn/default/notes resource "sless_trigger" "notes_crud_http" { name = "notes-http" type = "http" diff --git a/examples/notes-python/sql-runner.tf b/examples/notes-python/sql-runner.tf index 92bc8ad..29be82d 100644 --- a/examples/notes-python/sql-runner.tf +++ b/examples/notes-python/sql-runner.tf @@ -5,17 +5,6 @@ # Это сделано намеренно: функция выполняет произвольный SQL, и открывать её # наружу через HTTP было бы небезопасно. -# Упаковка исходников в zip для загрузки в кластер. -# archive_file пересоздаёт zip при изменении любого файла в source_dir. -data "archive_file" "sql_runner_zip" { - type = "zip" - source_dir = "${path.module}/code/sql-runner" - output_path = "${path.module}/dist/sql-runner.zip" -} - -# Сама функция в кластере. -# entrypoint = "sql_runner.run_sql" → файл sql_runner.py, функция run_sql(). -# memory_mb=128 достаточно — DDL запросы не требуют памяти на вычисления. resource "sless_function" "sql_runner" { name = "sql-runner" runtime = "python3.11" @@ -27,7 +16,5 @@ resource "sless_function" "sql_runner" { PG_DSN = var.pg_dsn } - code_path = data.archive_file.sql_runner_zip.output_path - # code_hash — при изменении кода terraform пересобирает образ функции - code_hash = filesha256("${path.module}/code/sql-runner/sql_runner.py") + source_dir = "${path.module}/code/sql-runner" } diff --git a/examples/simple-node/main.tf b/examples/simple-node/main.tf index 8400e2c..9cb2843 100644 --- a/examples/simple-node/main.tf +++ b/examples/simple-node/main.tf @@ -19,11 +19,7 @@ terraform { required_providers { sless = { source = "terra.k8c.ru/naeel/sless" - version = "~> 0.1.9" - } - archive = { - source = "hashicorp/archive" - version = "~> 2.0" + version = "~> 0.1.10" } } } diff --git a/examples/simple-node/time-display.tf b/examples/simple-node/time-display.tf index 0779f74..7d29bfe 100644 --- a/examples/simple-node/time-display.tf +++ b/examples/simple-node/time-display.tf @@ -1,14 +1,7 @@ -# Создано: 2026-03-09 +# Создано: 2026-03-09 / Изменено: 2026-03-09 # time-display.tf — HTTP-функция, доступная по URL после apply. # Получает результат джоба (из time-getter.tf) через переменную окружения JOB_TIME. -# Упаковываем код функции в zip для загрузки -data "archive_file" "time_display_zip" { - type = "zip" - source_dir = "${path.module}/code/time_display" - output_path = "${path.module}/dist/time_display.zip" -} - # HTTP-функция — отвечает на запросы по URL из outputs.tf resource "sless_function" "time_display" { name = "simple-node-time-display" # уникальное имя в namespace @@ -22,8 +15,7 @@ resource "sless_function" "time_display" { JOB_TIME = sless_job.run_getter.message } - code_path = data.archive_file.time_display_zip.output_path - code_hash = filesha256("${path.module}/code/time_display/time_display.js") # для пересборки при изменении кода + source_dir = "${path.module}/code/time_display" depends_on = [sless_job.run_getter] # ждём завершения джоба перед деплоем функции } diff --git a/examples/simple-node/time-getter.tf b/examples/simple-node/time-getter.tf index b1212c2..426bbcc 100644 --- a/examples/simple-node/time-getter.tf +++ b/examples/simple-node/time-getter.tf @@ -1,15 +1,8 @@ -# Создано: 2026-03-09 +# Создано: 2026-03-09 / Изменено: 2026-03-09 # time-getter.tf — скрипт который запускается ОДИН РАЗ при terraform apply. # После запуска его результат доступен через: sless_job.run_getter.message # Смотри time-display.tf — там этот результат передаётся в функцию. -# Упаковываем код скрипта в zip для загрузки -data "archive_file" "time_getter_zip" { - type = "zip" - source_dir = "${path.module}/code/time_getter" - output_path = "${path.module}/dist/time_getter.zip" -} - # Функция для скрипта — без HTTP-триггера, вызывается только через джоб ниже resource "sless_function" "time_getter" { name = "simple-node-time-getter" # уникальное имя в namespace @@ -17,8 +10,7 @@ resource "sless_function" "time_getter" { entrypoint = "time_getter.getTime" # файл.функция в code/time_getter/ memory_mb = 64 - code_path = data.archive_file.time_getter_zip.output_path - code_hash = filesha256("${path.module}/code/time_getter/time_getter.js") # для пересборки при изменении кода + source_dir = "${path.module}/code/time_getter" } # Джоб — запускает функцию time_getter один раз прямо при apply. diff --git a/examples/simple-python/main.tf b/examples/simple-python/main.tf index 5aff48e..5032776 100644 --- a/examples/simple-python/main.tf +++ b/examples/simple-python/main.tf @@ -18,11 +18,7 @@ terraform { required_providers { sless = { source = "terra.k8c.ru/naeel/sless" - version = "~> 0.1.9" - } - archive = { - source = "hashicorp/archive" - version = "~> 2.0" + version = "~> 0.1.10" } } } diff --git a/examples/simple-python/time-display.tf b/examples/simple-python/time-display.tf index 236a0de..c5db376 100644 --- a/examples/simple-python/time-display.tf +++ b/examples/simple-python/time-display.tf @@ -1,14 +1,7 @@ -# Создано: 2026-03-09 +# Создано: 2026-03-09 / Изменено: 2026-03-09 # time-display.tf — HTTP-функция, доступная по URL после apply. # Получает результат джоба (из time-getter.tf) через переменную окружения JOB_TIME. -# Упаковываем код функции в zip для загрузки -data "archive_file" "time_display_zip" { - type = "zip" - source_dir = "${path.module}/code/time_display" - output_path = "${path.module}/dist/time_display.zip" -} - # HTTP-функция — отвечает на запросы по URL из outputs.tf resource "sless_function" "time_display" { name = "simple-py-time-display" # уникальное имя в namespace @@ -22,8 +15,7 @@ resource "sless_function" "time_display" { JOB_TIME = sless_job.run_getter.message } - code_path = data.archive_file.time_display_zip.output_path - code_hash = filesha256("${path.module}/code/time_display/time_display.py") # для пересборки при изменении кода + source_dir = "${path.module}/code/time_display" depends_on = [sless_job.run_getter] # ждём завершения джоба перед деплоем функции } diff --git a/examples/simple-python/time-getter.tf b/examples/simple-python/time-getter.tf index 3295ef0..fd57615 100644 --- a/examples/simple-python/time-getter.tf +++ b/examples/simple-python/time-getter.tf @@ -1,15 +1,8 @@ -# Создано: 2026-03-09 +# Создано: 2026-03-09 / Изменено: 2026-03-09 # time-getter.tf — скрипт который запускается ОДИН РАЗ при terraform apply. # После запуска его результат доступен через: sless_job.run_getter.message # Смотри time-display.tf — там этот результат передаётся в функцию. -# Упаковываем код скрипта в zip для загрузки -data "archive_file" "time_getter_zip" { - type = "zip" - source_dir = "${path.module}/code/time_getter" - output_path = "${path.module}/dist/time_getter.zip" -} - # Функция для скрипта — без HTTP-триггера, вызывается только через джоб ниже resource "sless_function" "time_getter" { name = "simple-py-time-getter" # уникальное имя в namespace @@ -17,8 +10,7 @@ resource "sless_function" "time_getter" { entrypoint = "time_getter.get_time" # файл.функция в code/time_getter/ memory_mb = 64 - code_path = data.archive_file.time_getter_zip.output_path - code_hash = filesha256("${path.module}/code/time_getter/time_getter.py") # для пересборки при изменении кода + source_dir = "${path.module}/code/time_getter" } # Джоб — запускает функцию time_getter один раз прямо при apply. diff --git a/terraform/provider/internal/client/client.go b/terraform/provider/internal/client/client.go index f5bea19..258f2a4 100644 --- a/terraform/provider/internal/client/client.go +++ b/terraform/provider/internal/client/client.go @@ -194,14 +194,19 @@ func (c *Client) UploadCode(ctx context.Context, ns, name, zipPath string) error return fmt.Errorf("open zip %q: %w", zipPath, err) } defer f.Close() + return c.UploadCodeReader(ctx, ns, name, filepath.Base(zipPath), f) +} +// UploadCodeReader — загружает код из произвольного io.Reader (например in-memory zip). +// filename используется только как имя файла в multipart-форме. +func (c *Client) UploadCodeReader(ctx context.Context, ns, name, filename string, r io.Reader) error { var buf bytes.Buffer mw := multipart.NewWriter(&buf) - fw, err := mw.CreateFormFile("code", filepath.Base(zipPath)) + fw, err := mw.CreateFormFile("code", filename) if err != nil { return fmt.Errorf("create form file: %w", err) } - if _, err := io.Copy(fw, f); err != nil { + if _, err := io.Copy(fw, r); err != nil { return fmt.Errorf("copy zip: %w", err) } mw.Close() diff --git a/terraform/provider/internal/resources/function_resource.go b/terraform/provider/internal/resources/function_resource.go index c090f87..f8fb358 100644 --- a/terraform/provider/internal/resources/function_resource.go +++ b/terraform/provider/internal/resources/function_resource.go @@ -17,8 +17,16 @@ package resources import ( + "archive/zip" + "bytes" "context" + "crypto/sha256" + "encoding/hex" "fmt" + "io/fs" + "os" + "path/filepath" + "sort" "time" "terraform-provider-sless/internal/client" @@ -57,8 +65,11 @@ type FunctionModel struct { TimeoutSec types.Int64 `tfsdk:"timeout_sec"` EnvVars types.Map `tfsdk:"env_vars"` CodePath types.String `tfsdk:"code_path"` - // code_hash — sha256/md5 zip-файла, пользователь задаёт через filemd5(). - // Изменение hash → провайдер перезагружает код и запускает пересборку. + // source_dir — директория с исходниками. Провайдер сам упакует в zip и загрузит. + // Взаимоисключающе с code_path. Не требует hashicorp/archive. + SourceDir types.String `tfsdk:"source_dir"` + // code_hash — sha256 содержимого. При code_path: задаётся вручную. + // При source_dir: провайдер вычисляет сам и хранит в state для детекции изменений. CodeHash types.String `tfsdk:"code_hash"` // build_timeout_sec — максимальное ожидание kaniko-сборки. Дефолт 300 сек. BuildTimeoutSec types.Int64 `tfsdk:"build_timeout_sec"` @@ -117,9 +128,15 @@ func (r *FunctionResource) Schema(_ context.Context, _ resource.SchemaRequest, r "code_path": schema.StringAttribute{ Optional: true, }, - // code_hash — задаётся через filemd5("./handler.zip") в .tf + // source_dir — путь к директории с исходниками. Провайдер сам соберёт zip без archive provider. + "source_dir": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "Путь к директории с исходным кодом. Провайдер сам упакует в zip. Не нужен archive provider.", + }, + // code_hash — при code_path: задаётся вручную; при source_dir: вычисляется автоматически. "code_hash": schema.StringAttribute{ Optional: true, + Computed: true, }, // build_timeout_sec — таймаут ожидания kaniko-сборки. Дефолт 300 сек (5 мин). // Увеличь если функция с тяжёлыми зависимостями (например torch). @@ -187,11 +204,29 @@ func (r *FunctionResource) Create(ctx context.Context, req resource.CreateReques return } - if !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" { + var codeUploaded bool + if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" { + // source_dir: провайдер сам собирает zip и вычисляет hash + zipData, hash, err := zipDir(plan.SourceDir.ValueString()) + if err != nil { + resp.Diagnostics.AddError("zip source_dir", err.Error()) + return + } + if err := r.client.UploadCodeReader(ctx, ns, fn.Name, "code.zip", bytes.NewReader(zipData)); err != nil { + resp.Diagnostics.AddError("upload code", err.Error()) + return + } + plan.CodeHash = types.StringValue(hash) + codeUploaded = true + } else if !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" { + // code_path: старый путь, для обратной совместимости if err := r.client.UploadCode(ctx, ns, fn.Name, plan.CodePath.ValueString()); err != nil { resp.Diagnostics.AddError("upload code", err.Error()) return } + codeUploaded = true + } + if codeUploaded { buildSec := plan.BuildTimeoutSec.ValueInt64() if buildSec <= 0 { buildSec = defaultBuildTimeoutSec @@ -257,13 +292,32 @@ func (r *FunctionResource) Update(ctx context.Context, req resource.UpdateReques return } - // Перезагружаем код только если code_hash изменился - // Это предотвращает ненужные пересборки при apply без изменений кода - if !plan.CodeHash.Equal(state.CodeHash) && !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" { + var codeUploaded bool + if !plan.SourceDir.IsNull() && plan.SourceDir.ValueString() != "" { + // source_dir: пересчитываем hash и загружаем только если изменился + zipData, hash, err := zipDir(plan.SourceDir.ValueString()) + if err != nil { + resp.Diagnostics.AddError("zip source_dir", err.Error()) + return + } + newHash := types.StringValue(hash) + if !newHash.Equal(state.CodeHash) { + if err := r.client.UploadCodeReader(ctx, ns, name, "code.zip", bytes.NewReader(zipData)); err != nil { + resp.Diagnostics.AddError("upload code", err.Error()) + return + } + codeUploaded = true + } + plan.CodeHash = newHash + } else if !plan.CodeHash.Equal(state.CodeHash) && !plan.CodePath.IsNull() && plan.CodePath.ValueString() != "" { + // code_path: загружаем только если code_hash изменился вручную if err := r.client.UploadCode(ctx, ns, name, plan.CodePath.ValueString()); err != nil { resp.Diagnostics.AddError("upload code", err.Error()) return } + codeUploaded = true + } + if codeUploaded { buildSec := plan.BuildTimeoutSec.ValueInt64() if buildSec <= 0 { buildSec = defaultBuildTimeoutSec @@ -306,6 +360,7 @@ func fnToModel(plan FunctionModel, fn *client.FunctionResponse) FunctionModel { TimeoutSec: types.Int64Value(int64(fn.TimeoutSec)), EnvVars: plan.EnvVars, // API возвращает null для пустого map — берём из plan CodePath: plan.CodePath, + SourceDir: plan.SourceDir, CodeHash: plan.CodeHash, BuildTimeoutSec: buildTimeoutSec, Phase: types.StringValue(fn.Phase), @@ -313,6 +368,53 @@ func fnToModel(plan FunctionModel, fn *client.FunctionResponse) FunctionModel { } } +// zipDir упаковывает директорию dir в zip-архив (in-memory) и возвращает байты + SHA256 содержимого. +// Сортирует файлы по пути для детерминированного хеша — одинаковый код даёт одинаковый hash. +func zipDir(dir string) ([]byte, string, error) { + var paths []string + err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + paths = append(paths, path) + } + return nil + }) + if err != nil { + return nil, "", fmt.Errorf("walk dir %q: %w", dir, err) + } + sort.Strings(paths) + + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + h := sha256.New() + for _, path := range paths { + rel, err := filepath.Rel(dir, path) + if err != nil { + return nil, "", err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, "", fmt.Errorf("read %q: %w", path, err) + } + fw, err := zw.Create(rel) + if err != nil { + return nil, "", fmt.Errorf("zip create %q: %w", rel, err) + } + if _, err := fw.Write(data); err != nil { + return nil, "", fmt.Errorf("zip write %q: %w", rel, err) + } + // хешируем путь + содержимое для надёжной детекции изменений + h.Write([]byte(rel)) + h.Write(data) + } + if err := zw.Close(); err != nil { + return nil, "", fmt.Errorf("zip close: %w", err) + } + return buf.Bytes(), hex.EncodeToString(h.Sum(nil)), nil +} + // mapToStringMap конвертирует types.Map → map[string]string. func mapToStringMap(ctx context.Context, m types.Map) (map[string]string, diag.Diagnostics) { if m.IsNull() || m.IsUnknown() {