From d67b9745a834c3421dc6d657109e692638809750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CNaeel=E2=80=9D?= Date: Sun, 8 Mar 2026 10:10:32 +0400 Subject: [PATCH] feat: trigger.enabled + job.run_id lifecycle control (operator v0.1.6, provider v0.1.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TriggerSpec.Enabled bool (default=true): enabled=false масштабирует Deployment до 0 - FunctionJobSpec.RunID int64 (default=0): run_id=0 = skip, >0 = run - API: PATCH /v1/namespaces/{ns}/triggers/{name} (UpdateTrigger) - Provider: enabled attribute (Optional, Computed, in-place update) - Provider: run_id attribute (Optional, Computed, default=0, RequiresReplace) - operator image: naeel/sless-operator:v0.1.6 - provider: terra.k8c.ru/naeel/sless v0.1.4 --- api/v1alpha1/job_types.go | 9 +- api/v1alpha1/trigger_types.go | 8 +- api/v1alpha1/zz_generated.deepcopy.go | 196 +++++++++--------- .../bases/sless.kube5s.ru_functionjobs.yaml | 10 + .../crd/bases/sless.kube5s.ru_triggers.yaml | 8 + config/rbac/role.yaml | 122 +++++++++-- controllers/functionjob_controller.go | 14 +- controllers/trigger_controller.go | 33 ++- deployments/k8s/operator.yaml | 2 +- doc/decisions/log.md | 45 ++++ doc/progress.md | 2 + examples/hello-node/http.tf | 2 + examples/hello-node/job.tf | 2 + examples/hello-node/main.tf | 2 +- internal/api/handler/jobs.go | 7 +- internal/api/handler/triggers.go | 52 ++++- internal/api/router.go | 3 +- terraform/provider/internal/client/client.go | 31 ++- .../internal/resources/job_resource.go | 52 ++++- .../internal/resources/trigger_resource.go | 52 ++++- 20 files changed, 509 insertions(+), 143 deletions(-) diff --git a/api/v1alpha1/job_types.go b/api/v1alpha1/job_types.go index bc8122b..eec8504 100644 --- a/api/v1alpha1/job_types.go +++ b/api/v1alpha1/job_types.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-08 // Описание CRD FunctionJob — одноразовый запуск функции. // Отдельный ресурс (не Trigger) потому что семантика принципиально другая: // - Trigger: постоянно живёт, описывает КАК функцию вызывают (http/cron) @@ -15,6 +15,13 @@ import ( // FunctionJobSpec — параметры одноразового запуска функции. type FunctionJobSpec struct { + // RunID — идентификатор запуска. 0 = не запускать. + // Каждое ненулевое значение уникально идентифицирует запуск. + // Увеличь RunID (1→2→3) для повторного запуска джоба. + // При RunID=0 FunctionJob создаётся в кластере, но k8s Job не запускается. + // +kubebuilder:default=0 + RunID int64 `json:"runId"` + // FunctionRef — имя Function ресурса в том же namespace // +kubebuilder:validation:Required FunctionRef string `json:"functionRef"` diff --git a/api/v1alpha1/trigger_types.go b/api/v1alpha1/trigger_types.go index 17f8a1f..f550b4b 100644 --- a/api/v1alpha1/trigger_types.go +++ b/api/v1alpha1/trigger_types.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-08 // Описание CRD Trigger — триггер для функции (HTTP или Cron). // Один Trigger ссылается на одну Function и определяет способ вызова. @@ -20,6 +20,12 @@ const ( // TriggerSpec — желаемое состояние триггера. type TriggerSpec struct { + // Enabled — если false, Deployment функции масштабируется до 0 (функция не принимает запросы). + // При true — функция запускается. По умолчанию true. + // Позволяет "заморозить" функцию без удаления ресурса. + // +kubebuilder:default=true + Enabled bool `json:"enabled"` + // FunctionRef — имя Function ресурса в том же namespace // +kubebuilder:validation:Required FunctionRef string `json:"functionRef"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c51a146..601b434 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -21,7 +21,7 @@ limitations under the License. package v1alpha1 import ( - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1" runtime "k8s.io/apimachinery/pkg/runtime" ) @@ -52,6 +52,103 @@ func (in *Function) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FunctionJob) DeepCopyInto(out *FunctionJob) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + out.Spec = in.Spec + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJob. +func (in *FunctionJob) DeepCopy() *FunctionJob { + if in == nil { + return nil + } + out := new(FunctionJob) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FunctionJob) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FunctionJobList) DeepCopyInto(out *FunctionJobList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]FunctionJob, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobList. +func (in *FunctionJobList) DeepCopy() *FunctionJobList { + if in == nil { + return nil + } + out := new(FunctionJobList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *FunctionJobList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FunctionJobSpec) DeepCopyInto(out *FunctionJobSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobSpec. +func (in *FunctionJobSpec) DeepCopy() *FunctionJobSpec { + if in == nil { + return nil + } + out := new(FunctionJobSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FunctionJobStatus) DeepCopyInto(out *FunctionJobStatus) { + *out = *in + if in.StartTime != nil { + in, out := &in.StartTime, &out.StartTime + *out = (*in).DeepCopy() + } + if in.CompletionTime != nil { + in, out := &in.CompletionTime, &out.CompletionTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobStatus. +func (in *FunctionJobStatus) DeepCopy() *FunctionJobStatus { + if in == nil { + return nil + } + out := new(FunctionJobStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *FunctionList) DeepCopyInto(out *FunctionList) { *out = *in @@ -224,100 +321,3 @@ func (in *TriggerStatus) DeepCopy() *TriggerStatus { in.DeepCopyInto(out) return out } - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FunctionJob) DeepCopyInto(out *FunctionJob) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec - in.Status.DeepCopyInto(&out.Status) -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJob. -func (in *FunctionJob) DeepCopy() *FunctionJob { - if in == nil { - return nil - } - out := new(FunctionJob) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *FunctionJob) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FunctionJobList) DeepCopyInto(out *FunctionJobList) { - *out = *in - out.TypeMeta = in.TypeMeta - in.ListMeta.DeepCopyInto(&out.ListMeta) - if in.Items != nil { - in, out := &in.Items, &out.Items - *out = make([]FunctionJob, len(*in)) - for i := range *in { - (*in)[i].DeepCopyInto(&(*out)[i]) - } - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobList. -func (in *FunctionJobList) DeepCopy() *FunctionJobList { - if in == nil { - return nil - } - out := new(FunctionJobList) - in.DeepCopyInto(out) - return out -} - -// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. -func (in *FunctionJobList) DeepCopyObject() runtime.Object { - if c := in.DeepCopy(); c != nil { - return c - } - return nil -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FunctionJobSpec) DeepCopyInto(out *FunctionJobSpec) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobSpec. -func (in *FunctionJobSpec) DeepCopy() *FunctionJobSpec { - if in == nil { - return nil - } - out := new(FunctionJobSpec) - in.DeepCopyInto(out) - return out -} - -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *FunctionJobStatus) DeepCopyInto(out *FunctionJobStatus) { - *out = *in - if in.StartTime != nil { - in, out := &in.StartTime, &out.StartTime - *out = (*in).DeepCopy() - } - if in.CompletionTime != nil { - in, out := &in.CompletionTime, &out.CompletionTime - *out = (*in).DeepCopy() - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FunctionJobStatus. -func (in *FunctionJobStatus) DeepCopy() *FunctionJobStatus { - if in == nil { - return nil - } - out := new(FunctionJobStatus) - in.DeepCopyInto(out) - return out -} diff --git a/config/crd/bases/sless.kube5s.ru_functionjobs.yaml b/config/crd/bases/sless.kube5s.ru_functionjobs.yaml index 318034b..9c48003 100644 --- a/config/crd/bases/sless.kube5s.ru_functionjobs.yaml +++ b/config/crd/bases/sless.kube5s.ru_functionjobs.yaml @@ -62,8 +62,18 @@ spec: functionRef: description: FunctionRef — имя Function ресурса в том же namespace type: string + runId: + default: 0 + description: |- + RunID — идентификатор запуска. 0 = не запускать. + Каждое ненулевое значение уникально идентифицирует запуск. + Увеличь RunID (1→2→3) для повторного запуска джоба. + При RunID=0 FunctionJob создаётся в кластере, но k8s Job не запускается. + format: int64 + type: integer required: - functionRef + - runId type: object status: description: FunctionJobStatus — наблюдаемое состояние (заполняет контроллер). diff --git a/config/crd/bases/sless.kube5s.ru_triggers.yaml b/config/crd/bases/sless.kube5s.ru_triggers.yaml index dafe638..25015e6 100644 --- a/config/crd/bases/sless.kube5s.ru_triggers.yaml +++ b/config/crd/bases/sless.kube5s.ru_triggers.yaml @@ -55,6 +55,13 @@ spec: spec: description: TriggerSpec — желаемое состояние триггера. properties: + enabled: + default: true + description: |- + Enabled — если false, Deployment функции масштабируется до 0 (функция не принимает запросы). + При true — функция запускается. По умолчанию true. + Позволяет "заморозить" функцию без удаления ресурса. + type: boolean functionRef: description: FunctionRef — имя Function ресурса в том же namespace type: string @@ -76,6 +83,7 @@ spec: - cron type: string required: + - enabled - functionRef - type type: object diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 5c9a12c..25947f6 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -4,6 +4,108 @@ kind: ClusterRole metadata: name: manager-role rules: +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - "" + resources: + - namespaces + verbs: + - create + - get + - list + - watch +- apiGroups: + - "" + resources: + - services + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - apps + resources: + - deployments + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - batch + resources: + - cronjobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - batch + resources: + - jobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - sless.kube5s.ru + resources: + - functionjobs + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - sless.kube5s.ru + resources: + - functionjobs/finalizers + verbs: + - update +- apiGroups: + - sless.kube5s.ru + resources: + - functionjobs/status + verbs: + - get + - patch + - update - apiGroups: - sless.kube5s.ru resources: @@ -30,29 +132,9 @@ rules: - get - patch - update -- apiGroups: - - sless.kube5s.ru - resources: - - triggers - verbs: - - create - - delete - - get - - list - - patch - - update - - watch - apiGroups: - sless.kube5s.ru resources: - triggers/finalizers verbs: - update -- apiGroups: - - sless.kube5s.ru - resources: - - triggers/status - verbs: - - get - - patch - - update diff --git a/controllers/functionjob_controller.go b/controllers/functionjob_controller.go index 6b94c6e..7d34a59 100644 --- a/controllers/functionjob_controller.go +++ b/controllers/functionjob_controller.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-08 // FunctionJobReconciler — контроллер одноразовых запусков функций. // При создании FunctionJob: // 1. Ждёт пока Function станет Ready @@ -58,6 +58,18 @@ func (r *FunctionJobReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{}, nil } + // RunID=0 означает "не запускать". + // Позволяет создать FunctionJob в кластере, но запустить его явно позже + // (увеличив RunID > 0 через Terraform или kubectl patch). + if fj.Spec.RunID == 0 { + if fj.Status.Phase != "Skipped" { + fj.Status.Phase = "Skipped" + fj.Status.Message = "run_id=0: set run_id>0 to execute" + _ = r.Status().Update(ctx, fj) + } + return ctrl.Result{}, nil + } + // Проверяем что Function существует и готова fn := &slessv1alpha1.Function{} if err := r.Get(ctx, client.ObjectKey{Name: fj.Spec.FunctionRef, Namespace: fj.Namespace}, fn); err != nil { diff --git a/controllers/trigger_controller.go b/controllers/trigger_controller.go index 5888054..8264ec9 100644 --- a/controllers/trigger_controller.go +++ b/controllers/trigger_controller.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-08 // TriggerReconciler — контроллер триггеров. // HTTP триггер: создаёт Service + Ingress в namespace функции. // Cron триггер: создаёт k8s CronJob который периодически вызывает функцию по внутреннему URL. @@ -9,6 +9,7 @@ import ( "context" "fmt" + appsv1 "k8s.io/api/apps/v1" batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" netv1 "k8s.io/api/networking/v1" @@ -84,6 +85,36 @@ func (r *TriggerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct return ctrl.Result{}, nil } + // Управляем масштабом Deployment функции в зависимости от Enabled. + // Deployment находится в namespace sless-fn-{userNS}, имя = имя функции. + // Это позволяет "заморозить" функцию без удаления ресурса. + deployNS := "sless-fn-" + tr.Namespace + dep := &appsv1.Deployment{} + if err := r.Get(ctx, client.ObjectKey{Name: fn.Name, Namespace: deployNS}, dep); err == nil { + var wantReplicas int32 + if tr.Spec.Enabled { + wantReplicas = 1 + } else { + wantReplicas = 0 + } + // Обновляем только если значение изменилось, чтобы не создавать лишних событий + if dep.Spec.Replicas == nil || *dep.Spec.Replicas != wantReplicas { + dep.Spec.Replicas = &wantReplicas + if err := r.Update(ctx, dep); err != nil { + return ctrl.Result{}, fmt.Errorf("scale deployment replicas: %w", err) + } + logger.Info("scaled deployment", "function", fn.Name, "replicas", wantReplicas) + } + } + + // Если триггер выключен — останавливаем reconcile здесь, ресурсы не создаём + if !tr.Spec.Enabled { + tr.Status.Active = false + tr.Status.Message = "disabled (enabled=false)" + _ = r.Status().Update(ctx, tr) + return ctrl.Result{}, nil + } + switch tr.Spec.Type { case slessv1alpha1.TriggerTypeHTTP: logger.Info("reconcile http trigger", "trigger", tr.Name) diff --git a/deployments/k8s/operator.yaml b/deployments/k8s/operator.yaml index 26966a1..58887e7 100644 --- a/deployments/k8s/operator.yaml +++ b/deployments/k8s/operator.yaml @@ -70,7 +70,7 @@ spec: containers: - name: operator # При обновлении версии оператора — менять тег здесь (не latest!) - image: naeel/sless-operator:v0.1.5 + image: naeel/sless-operator:v0.1.6 # Always — чтобы всегда тянуть по точному тегу (не кешировать старый) imagePullPolicy: Always ports: diff --git a/doc/decisions/log.md b/doc/decisions/log.md index 0b6c57f..94186a6 100644 --- a/doc/decisions/log.md +++ b/doc/decisions/log.md @@ -159,3 +159,48 @@ **URL функции:** `https://sless-api.kube5s.ru/fn/{namespace}/{name}` **E2E:** `curl https://sless-api.kube5s.ru/fn/default/hello-node` → `{"message":"Hello, Naeel! (nodejs20)"}` + +--- + +## 2026-03-08 — Lifecycle control: trigger.enabled + job.run_id + +**Задача:** управление жизненным циклом ресурсов без удаления. + +### trigger.enabled + +**Проблема:** нет способа "заморозить" функцию без удаления Trigger/Function ( +освобождение ресурсов под праздники, дебаггинг и т.д.). + +**Решение:** `enabled bool` (по умолчанию `true`) в `TriggerSpec`. +- `enabled=false` → trigger_controller масштабирует Deployment функции до 0 реплик. +- Функция не принимает запросы, не потребляет CPU (pod не запущен). +- Изменение **не** пересоздаёт ресурс (нет RequiresReplace) — in-place через PATCH. + +**Реализация:** +- `api/v1alpha1/trigger_types.go` — `Enabled bool` в TriggerSpec, `//+kubebuilder:default=true` +- `controllers/trigger_controller.go` — патчит Deployment replicas=0/1 в зависимости от Enabled +- `internal/api/handler/triggers.go` — `UpdateTrigger` handler (PATCH), поле `enabled` в request/response +- `internal/api/router.go` — `PATCH /v1/namespaces/{namespace}/triggers/{name}` +- `internal/client/client.go` — `TriggerUpdateRequest`, `UpdateTrigger()` метод +- `terraform/provider/internal/resources/trigger_resource.go` — атрибут `enabled` (Optional+Computed, default=true), реализован `Update` метод + +### job.run_id + +**Проблема:** нет способа создать FunctionJob "отложенным" — с явным контролем когда запускать. +Также нет механизма повторного запуска с сохранением структуры ресурса. + +**Решение:** `run_id int64` (по умолчанию `0`) в `FunctionJobSpec`. +- `run_id=0` → FunctionJob создаётся в k8s, но k8s Job не запускается (phase=Skipped). +- `run_id>0` → запускает Job. Увеличение значения (1→2→3) = повторный запуск через пересоздание. + +**Реализация:** +- `api/v1alpha1/job_types.go` — `RunID int64` в FunctionJobSpec, `//+kubebuilder:default=0` +- `controllers/functionjob_controller.go` — если RunID==0 → устанавливает phase=Skipped, return +- `internal/api/handler/jobs.go` — поле `run_id` в jobRequest/jobResponse +- `internal/client/client.go` — `RunID int64` в JobRequest/JobResponse +- `terraform/provider/internal/resources/job_resource.go` — атрибут `run_id` (RequiresReplace, default=0). Если run_id=0 → не ждёт завершения, phase=Skipped сразу в state. + +**Версии:** +- operator: `naeel/sless-operator:v0.1.6` +- provider: `terra.k8c.ru/naeel/sless v0.1.4` + diff --git a/doc/progress.md b/doc/progress.md index d5a9da5..f80a9cc 100644 --- a/doc/progress.md +++ b/doc/progress.md @@ -104,3 +104,5 @@ | 3 | Метрики → Victoria Metrics | ⏳ | | | 4 | Managed PostgreSQL от провайдера | ⏳ | сейчас postgres в k8s | | 5 | Pre-warm для cron триггеров | ⏳ | TriggerSpec.PreWarmSeconds — поле есть, логика не реализована | +| 11 | trigger.enabled | ✅ | enabled=false → Deployment replicas=0, in-place update через PATCH | +| 12 | job.run_id | ✅ | run_id=0 → skip, run_id>0 → execute. Повторный запуск = увеличить run_id | diff --git a/examples/hello-node/http.tf b/examples/hello-node/http.tf index ee255cd..e0678ca 100644 --- a/examples/hello-node/http.tf +++ b/examples/hello-node/http.tf @@ -30,6 +30,8 @@ resource "sless_trigger" "hello_http" { name = "hello-http-trigger" type = "http" function = sless_function.hello_http.name + # enabled = false — чтобы заморозить функцию (не удаляя ресурс), потом поставить зновь true + enabled = true } output "trigger_url" { diff --git a/examples/hello-node/job.tf b/examples/hello-node/job.tf index 23097dc..8687f77 100644 --- a/examples/hello-node/job.tf +++ b/examples/hello-node/job.tf @@ -28,12 +28,14 @@ resource "sless_function" "hello_job" { } # Одноразовый запуск. Все поля immutable — изменение любого пересоздаёт джоб. +# run_id: 0 = не запускать, 1+ = запустить. Для повторного запуска увеличь run_id (1→2→3...). resource "sless_job" "hello_run" { namespace = "default" name = "hello-run" function = sless_function.hello_job.name event_json = jsonencode({ numbers = [1, 2, 3, 4, 5] }) wait_timeout_sec = 120 + run_id = 1 } output "job_phase" { diff --git a/examples/hello-node/main.tf b/examples/hello-node/main.tf index 0bd5b5c..6e11712 100644 --- a/examples/hello-node/main.tf +++ b/examples/hello-node/main.tf @@ -8,7 +8,7 @@ terraform { required_providers { sless = { source = "terra.k8c.ru/naeel/sless" - version = "~> 0.1.1" + version = "~> 0.1.4" } archive = { source = "hashicorp/archive" diff --git a/internal/api/handler/jobs.go b/internal/api/handler/jobs.go index 7b5e3da..30010c3 100644 --- a/internal/api/handler/jobs.go +++ b/internal/api/handler/jobs.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-08 // jobs.go — CRUD handlers для FunctionJob CRD. // Создаёт/читает/удаляет k8s FunctionJob ресурсы. // Namespace берётся из URL: /v1/namespaces/{namespace}/jobs/{name} @@ -21,6 +21,8 @@ type jobRequest struct { Name string `json:"name"` FunctionRef string `json:"function"` EventJSON string `json:"event_json,omitempty"` + // RunID — идентификатор запуска. 0 = создать без запуска, >0 = запустить. + RunID int64 `json:"run_id"` } // jobResponse — ответ при чтении / создании FunctionJob @@ -29,6 +31,7 @@ type jobResponse struct { Namespace string `json:"namespace"` FunctionRef string `json:"function"` EventJSON string `json:"event_json"` + RunID int64 `json:"run_id"` Phase string `json:"phase"` JobName string `json:"job_name,omitempty"` StartTime string `json:"start_time,omitempty"` @@ -43,6 +46,7 @@ func jobToResponse(j *slessv1alpha1.FunctionJob) jobResponse { Namespace: j.Namespace, FunctionRef: j.Spec.FunctionRef, EventJSON: j.Spec.EventJSON, + RunID: j.Spec.RunID, Phase: string(j.Status.Phase), JobName: j.Status.JobName, Message: j.Status.Message, @@ -86,6 +90,7 @@ func (h *Handler) CreateJob(w http.ResponseWriter, r *http.Request) { Spec: slessv1alpha1.FunctionJobSpec{ FunctionRef: req.FunctionRef, EventJSON: req.EventJSON, + RunID: req.RunID, }, } diff --git a/internal/api/handler/triggers.go b/internal/api/handler/triggers.go index 3807e48..5f85395 100644 --- a/internal/api/handler/triggers.go +++ b/internal/api/handler/triggers.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-08 // triggers.go — CRUD handlers для Trigger CRD. // Триггеры привязаны к Function через FunctionRef. // Namespace берётся из URL: /v1/namespaces/{namespace}/triggers/{name} @@ -16,13 +16,16 @@ import ( slessv1alpha1 "gitea-naeel.giteak8s.services.ngcloud.ru/naeel/sless/api/v1alpha1" ) -// triggerRequest — тело запроса для создания триггера. +// triggerRequest — тело запроса для создания/обновления триггера. type triggerRequest struct { Name string `json:"name"` Type string `json:"type"` // http | cron FunctionRef string `json:"function"` // имя Function CRD Schedule string `json:"schedule"` // cron-расписание, только для type=cron PreWarmSeconds int32 `json:"pre_warm_seconds"` + // Enabled — по умолчанию true (включён). false = Deployment масштабируется до 0. + // Используем *bool чтобы различать nil (не передан) от false (явно выключен). + Enabled *bool `json:"enabled"` } // triggerResponse — ответ при чтении триггера. @@ -32,6 +35,7 @@ type triggerResponse struct { Type string `json:"type"` FunctionRef string `json:"function"` Schedule string `json:"schedule,omitempty"` + Enabled bool `json:"enabled"` Active bool `json:"active"` URL string `json:"url,omitempty"` Message string `json:"message,omitempty"` @@ -45,6 +49,7 @@ func trToResponse(tr *slessv1alpha1.Trigger) triggerResponse { Type: string(tr.Spec.Type), FunctionRef: tr.Spec.FunctionRef, Schedule: tr.Spec.Schedule, + Enabled: tr.Spec.Enabled, Active: tr.Status.Active, URL: tr.Status.URL, Message: tr.Status.Message, @@ -93,6 +98,8 @@ func (h *Handler) CreateTrigger(w http.ResponseWriter, r *http.Request) { FunctionRef: req.FunctionRef, Schedule: req.Schedule, PreWarmSeconds: req.PreWarmSeconds, + // По умолчанию enabled=true, если явно не передано false + Enabled: req.Enabled == nil || *req.Enabled, }, } if err := h.K8s.Create(r.Context(), tr); err != nil { @@ -141,3 +148,44 @@ func (h *Handler) DeleteTrigger(w http.ResponseWriter, r *http.Request) { } w.WriteHeader(http.StatusNoContent) } + +// UpdateTrigger — PATCH /v1/namespaces/{namespace}/triggers/{name} +// Позволяет изменить поля триггера без пересоздания (в частности enabled). +// Принимает частичный JSON: только переданные поля обновляются. +func (h *Handler) UpdateTrigger(w http.ResponseWriter, r *http.Request) { + ns := namespace(r) + name := pathVar(r, "name") + + var req triggerRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, errResp("invalid JSON: "+err.Error())) + return + } + + tr := &slessv1alpha1.Trigger{} + if err := h.K8s.Get(r.Context(), client.ObjectKey{Name: name, Namespace: ns}, tr); err != nil { + if errors.IsNotFound(err) { + writeJSON(w, http.StatusNotFound, errResp("trigger not found")) + return + } + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + + // Обновляем только явно переданные поля + if req.Enabled != nil { + tr.Spec.Enabled = *req.Enabled + } + if req.Schedule != "" { + tr.Spec.Schedule = req.Schedule + } + if req.PreWarmSeconds != 0 { + tr.Spec.PreWarmSeconds = req.PreWarmSeconds + } + + if err := h.K8s.Update(r.Context(), tr); err != nil { + writeJSON(w, http.StatusInternalServerError, errResp(err.Error())) + return + } + writeJSON(w, http.StatusOK, trToResponse(tr)) +} diff --git a/internal/api/router.go b/internal/api/router.go index 1681a75..7014f7d 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -1,4 +1,4 @@ -// Изменено: 2026-03-07 +// Изменено: 2026-03-08 // router.go — регистрация всех REST-маршрутов через gorilla/mux. // Все маршруты защищены Bearer-токеном (middleware.Auth). // Маршруты сгруппированы по /v1/namespaces/{namespace}/... @@ -45,6 +45,7 @@ func NewRouter(h *handler.Handler, apiToken string, log *slog.Logger) http.Handl v1.HandleFunc("/namespaces/{namespace}/triggers", h.ListTriggers).Methods(http.MethodGet) v1.HandleFunc("/namespaces/{namespace}/triggers", h.CreateTrigger).Methods(http.MethodPost) v1.HandleFunc("/namespaces/{namespace}/triggers/{name}", h.GetTrigger).Methods(http.MethodGet) + v1.HandleFunc("/namespaces/{namespace}/triggers/{name}", h.UpdateTrigger).Methods(http.MethodPatch) v1.HandleFunc("/namespaces/{namespace}/triggers/{name}", h.DeleteTrigger).Methods(http.MethodDelete) // Jobs CRUD — одноразовые запуски функций diff --git a/terraform/provider/internal/client/client.go b/terraform/provider/internal/client/client.go index 0cd07dc..5bda76d 100644 --- a/terraform/provider/internal/client/client.go +++ b/terraform/provider/internal/client/client.go @@ -1,4 +1,4 @@ -// 2026-03-07 +// 2026-03-08 // client.go — HTTP-клиент для REST API sless оператора. // Намеренно изолирован от terraform-plugin-framework — при переносе в nubes // этот файл кладётся в internal/core/ без изменений. @@ -68,6 +68,14 @@ type TriggerRequest struct { Type string `json:"type"` FunctionRef string `json:"function"` Schedule string `json:"schedule,omitempty"` + // Enabled: nil = не передавать (по умолчанию true) + Enabled *bool `json:"enabled"` +} + +// TriggerUpdateRequest — тело PATCH /v1/namespaces/{ns}/triggers/{name} +// Используем *bool чтобы различать nil (не передано) от false (явно выключен). +type TriggerUpdateRequest struct { + Enabled *bool `json:"enabled"` } // TriggerResponse — ответ GET /v1/namespaces/{ns}/triggers/{name} @@ -77,6 +85,7 @@ type TriggerResponse struct { Type string `json:"type"` FunctionRef string `json:"function"` Schedule string `json:"schedule"` + Enabled bool `json:"enabled"` Active bool `json:"active"` URL string `json:"url"` Message string `json:"message"` @@ -291,6 +300,23 @@ func (c *Client) DeleteTrigger(ctx context.Context, ns, name string) error { return nil } +// UpdateTrigger — PATCH /v1/namespaces/{ns}/triggers/{name} → 200 +// Позволяет изменить enabled без пересоздания триггера. +func (c *Client) UpdateTrigger(ctx context.Context, ns, name string, req TriggerUpdateRequest) (*TriggerResponse, error) { + url := fmt.Sprintf("%s/v1/namespaces/%s/triggers/%s", c.endpoint, ns, name) + resp, err := c.doJSON(ctx, http.MethodPatch, url, req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("update trigger: status %d: %s", resp.StatusCode, body) + } + var tr TriggerResponse + return &tr, json.NewDecoder(resp.Body).Decode(&tr) +} + // --- Job CRUD --- // JobRequest — тело POST /v1/namespaces/{ns}/jobs @@ -298,6 +324,8 @@ type JobRequest struct { Name string `json:"name"` FunctionRef string `json:"function"` EventJSON string `json:"event_json,omitempty"` + // RunID: 0 = создать без запуска, >0 = запустить + RunID int64 `json:"run_id"` } // JobResponse — ответ GET /v1/namespaces/{ns}/jobs/{name} @@ -306,6 +334,7 @@ type JobResponse struct { Namespace string `json:"namespace"` FunctionRef string `json:"function"` EventJSON string `json:"event_json"` + RunID int64 `json:"run_id"` Phase string `json:"phase"` JobName string `json:"job_name"` StartTime string `json:"start_time"` diff --git a/terraform/provider/internal/resources/job_resource.go b/terraform/provider/internal/resources/job_resource.go index 3df77d8..0d5a78e 100644 --- a/terraform/provider/internal/resources/job_resource.go +++ b/terraform/provider/internal/resources/job_resource.go @@ -1,4 +1,4 @@ -// 2026-03-07 +// 2026-03-08 // job_resource.go — Terraform ресурс sless_job. // // Lifecycle: @@ -6,12 +6,13 @@ // Create: POST /v1/namespaces/{ns}/jobs → WaitJobDone (10 мин) // Блокирует terraform apply до завершения джоба (Succeeded/Failed). // Если Failed — terraform apply падает с ошибкой. +// Если run_id=0 — джоб создаётся в k8s, но k8s Job не запускается. // Read: GET /v1/namespaces/{ns}/jobs/{name} → sync phase/timing в state // Delete: DELETE /v1/namespaces/{ns}/jobs/{name} // Семантически no-op (джоб уже выполнен), но убирает CR из кластера. // -// Update не поддерживается — любое изменение name/function/event_json требует пересоздания. -// Это корректно: job — одноразовое действие, нельзя "обновить" уже выполненное. +// run_id: значение 0 = создать без запуска. >0 = запустить/перезапустить джоб. +// run_id имеет RequiresReplace: изменение значения (1→2→3) триггерирует повторный запуск. package resources import ( @@ -23,6 +24,8 @@ import ( "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/resource/schema/int64planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" "github.com/hashicorp/terraform-plugin-framework/types" @@ -48,6 +51,9 @@ type JobModel struct { Name types.String `tfsdk:"name"` Function types.String `tfsdk:"function"` EventJSON types.String `tfsdk:"event_json"` + // RunID: 0 = не запускать (Skipped), 1+ = запустить/перезапустить. + // RequiresReplace: изменение = пересоздание FunctionJob → новый запуск. + RunID types.Int64 `tfsdk:"run_id"` // wait_timeout_sec — максимальное ожидание завершения джоба. Дефолт 600 сек. WaitTimeoutSec types.Int64 `tfsdk:"wait_timeout_sec"` Phase types.String `tfsdk:"phase"` @@ -90,8 +96,17 @@ func (r *JobResource) Schema(_ context.Context, _ resource.SchemaRequest, resp * PlanModifiers: []planmodifier.String{ stringplanmodifier.RequiresReplace(), }, - }, - // wait_timeout_sec — сколько ждать завершения джоба. Увеличь если код долго работает (например миграция БД). + }, // run_id: 0 = создать без запуска (Skipped), >0 = запустить. + // Изменение run_id (1→2→3...) триггерирует пересоздание = новый запуск. + "run_id": schema.Int64Attribute{ + Optional: true, + Computed: true, + Default: int64default.StaticInt64(0), + MarkdownDescription: "0 = не запускать; >0 = запустить. Увеличьте run_id для повторного запуска джоба.", + PlanModifiers: []planmodifier.Int64{ + int64planmodifier.RequiresReplace(), + }, + }, // wait_timeout_sec — сколько ждать завершения джоба. Увеличь если код долго работает (например миграция БД). "wait_timeout_sec": schema.Int64Attribute{ Optional: true, Computed: true, @@ -146,16 +161,42 @@ func (r *JobResource) Create(ctx context.Context, req resource.CreateRequest, re eventJSON = "{}" } + // run_id=0: создаём FunctionJob в k8s, но оператор не запустит k8s Job. + // Пользователь может поменять run_id > 0 позже чтобы запустить. + runID := plan.RunID.ValueInt64() _, err := r.client.CreateJob(ctx, ns, client.JobRequest{ Name: plan.Name.ValueString(), FunctionRef: plan.Function.ValueString(), EventJSON: eventJSON, + RunID: runID, }) if err != nil { resp.Diagnostics.AddError("create job", err.Error()) return } + // Если RunID=0 — не ждём завершения, пишем state сразу + if runID == 0 { + // state: Namespace/Name/Function из plan, RunID=0, Phase=Skipped, остальное empty + waitTimeoutSec := plan.WaitTimeoutSec + if waitTimeoutSec.IsNull() || waitTimeoutSec.IsUnknown() || waitTimeoutSec.ValueInt64() <= 0 { + waitTimeoutSec = types.Int64Value(defaultWaitTimeoutSec) + } + resp.Diagnostics.Append(resp.State.Set(ctx, JobModel{ + Namespace: types.StringValue(plan.Namespace.ValueString()), + Name: types.StringValue(plan.Name.ValueString()), + Function: types.StringValue(plan.Function.ValueString()), + EventJSON: plan.EventJSON, + RunID: types.Int64Value(0), + WaitTimeoutSec: waitTimeoutSec, + Phase: types.StringValue("Skipped"), + StartTime: types.StringValue(""), + CompletionTime: types.StringValue(""), + Message: types.StringValue("run_id=0: set run_id>0 to execute"), + })...) + return + } + // Блокируем apply до завершения джоба (Succeeded или Failed) waitSec := plan.WaitTimeoutSec.ValueInt64() if waitSec <= 0 { @@ -220,6 +261,7 @@ func jobToModel(plan JobModel, j *client.JobResponse) JobModel { Name: types.StringValue(j.Name), Function: types.StringValue(j.FunctionRef), EventJSON: plan.EventJSON, + RunID: types.Int64Value(j.RunID), WaitTimeoutSec: waitTimeoutSec, Phase: types.StringValue(j.Phase), StartTime: types.StringValue(j.StartTime), diff --git a/terraform/provider/internal/resources/trigger_resource.go b/terraform/provider/internal/resources/trigger_resource.go index 55c2018..76d0ae0 100644 --- a/terraform/provider/internal/resources/trigger_resource.go +++ b/terraform/provider/internal/resources/trigger_resource.go @@ -1,8 +1,8 @@ -// 2026-03-07 +// 2026-03-08 // trigger_resource.go — Terraform ресурс sless_trigger. // Поддерживает type=http (создаёт Service+Ingress) и type=cron (запускает по schedule). -// Все ключевые поля имеют RequiresReplace — API не поддерживает обновление триггеров. -// URL функции (для http-триггера) — computed, пишется оператором в status.url. +// enabled=false: масштабирует Deployment функции до 0 (не принимает запросы, не потребляет ресурсы). +// enabled не требует RequiresReplace — поддерживает in-place обновление через PATCH. package resources import ( @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" @@ -36,8 +37,11 @@ type TriggerModel struct { Type types.String `tfsdk:"type"` FunctionRef types.String `tfsdk:"function"` Schedule types.String `tfsdk:"schedule"` - Active types.Bool `tfsdk:"active"` - URL types.String `tfsdk:"url"` + // Enabled — false = Deployment масштабируется до 0, функция не потребляет ресурсы. + // Не требует пересоздания - изменяется in-place через PATCH. + Enabled types.Bool `tfsdk:"enabled"` + Active types.Bool `tfsdk:"active"` + URL types.String `tfsdk:"url"` } func (r *TriggerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { @@ -80,6 +84,13 @@ func (r *TriggerResource) Schema(_ context.Context, _ resource.SchemaRequest, re stringplanmodifier.RequiresReplace(), }, }, + // enabled: false → Deployment масштабируется до 0 (не удаляет ресурс). + // Не имеет RequiresReplace: применяется in-place через PATCH. + "enabled": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(true), + }, // active, url — только для чтения, вычисляются оператором "active": schema.BoolAttribute{ Computed: true, @@ -124,6 +135,7 @@ func (r *TriggerResource) Create(ctx context.Context, req resource.CreateRequest Type: plan.Type.ValueString(), FunctionRef: plan.FunctionRef.ValueString(), Schedule: plan.Schedule.ValueString(), + Enabled: boolPtr(plan.Enabled.ValueBool()), }) if err != nil { resp.Diagnostics.AddError("create trigger", err.Error()) @@ -153,10 +165,26 @@ func (r *TriggerResource) Read(ctx context.Context, req resource.ReadRequest, re resp.Diagnostics.Append(resp.State.Set(ctx, trToModel(tr))...) } -// Update — никогда не вызывается: все поля имеют RequiresReplace. -// Метод обязателен интерфейсом resource.Resource. -func (r *TriggerResource) Update(_ context.Context, _ resource.UpdateRequest, resp *resource.UpdateResponse) { - resp.Diagnostics.AddError("update not supported", "all trigger fields require replacement") +// Update — обновляет enabled через PATCH без пересоздания. +// единственное поле без RequiresReplace, поэтому Update срабатывает только если enabled изменился. +func (r *TriggerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state TriggerModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + enabled := plan.Enabled.ValueBool() + tr, err := r.client.UpdateTrigger(ctx, plan.Namespace.ValueString(), plan.Name.ValueString(), client.TriggerUpdateRequest{ + Enabled: &enabled, + }) + if err != nil { + resp.Diagnostics.AddError("update trigger", err.Error()) + return + } + + resp.Diagnostics.Append(resp.State.Set(ctx, trToModel(tr))...) } func (r *TriggerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { @@ -185,7 +213,13 @@ func trToModel(tr *client.TriggerResponse) TriggerModel { Type: types.StringValue(tr.Type), FunctionRef: types.StringValue(tr.FunctionRef), Schedule: schedule, + Enabled: types.BoolValue(tr.Enabled), Active: types.BoolValue(tr.Active), URL: types.StringValue(tr.URL), } } + +// boolPtr — хелпер для получения указателя на bool (API требует *bool). +func boolPtr(v bool) *bool { + return &v +}