Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ef480d1f8 | ||
|
|
df16b40a9d | ||
|
|
fbd565651a | ||
|
|
fe8f6a871b | ||
|
|
c9f97e2f4c | ||
|
|
4b8c776357 |
@@ -28,16 +28,16 @@
|
|||||||
1. Не трогать рабочий код без явного указания.
|
1. Не трогать рабочий код без явного указания.
|
||||||
|
|
||||||
2. Файлы редактируются локально:
|
2. Файлы редактируются локально:
|
||||||
~/fission
|
~/IoT
|
||||||
|
|
||||||
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
|
После ЛЮБЫХ изменений ОБЯЗАТЕЛЬНО синхронизировать на ВМ командой:
|
||||||
rsync -az \
|
rsync -az \
|
||||||
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||||
~/fission/ \
|
~/IoT/ \
|
||||||
naeel@5.172.178.213:~/terra/fission/
|
naeel@5.172.178.213:~/terra/IoT/
|
||||||
|
|
||||||
|
|
||||||
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission
|
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/IoT
|
||||||
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
|
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ:
|
||||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10 naeel@5.172.178.213 'КОМАНДА'
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -13,14 +13,14 @@
|
|||||||
|
|
||||||
## Файловая система (актуально)
|
## Файловая система (актуально)
|
||||||
|
|
||||||
1. Все файлы редактируются локально: `~/fission`
|
1. Все файлы редактируются локально: `~/IoT`
|
||||||
2. После любых изменений — обязательно rsync на ВМ:
|
2. После любых изменений — обязательно rsync на ВМ:
|
||||||
rsync -az \
|
rsync -az \
|
||||||
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
-e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no -o ConnectTimeout=10" \
|
||||||
~/fission/ \
|
~/IoT/ \
|
||||||
naeel@5.172.178.213:~/terra/fission/
|
naeel@5.172.178.213:~/terra/IoT/
|
||||||
|
|
||||||
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/fission
|
3. Git (add/commit/push) выполнять ЛОКАЛЬНО в ~/IoT
|
||||||
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
|
4. Docker, kubectl и другие инфраструктурные команды — только через SSH на ВМ
|
||||||
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
|
5. Перед запуском любой команды на ВМ обязательно убедиться, что синхронизация (rsync) выполнена
|
||||||
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
|
6. SCP, sshfs, remote_dev и маунты больше НЕ используются
|
||||||
@@ -86,7 +86,7 @@ LOG="test-results/$(date +%Y-%m-%d_%H-%M).log"
|
|||||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
|
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213 \
|
||||||
"bash ~/terra/fission/scripts/run_all.sh 2>&1 | tee ~/terra/fission/${LOG}"
|
"bash ~/terra/fission/scripts/run_all.sh 2>&1 | tee ~/terra/fission/${LOG}"
|
||||||
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
rsync -az -e "ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no" \
|
||||||
naeel@5.172.178.213:~/terra/fission/test-results/ ~/fission/test-results/
|
naeel@5.172.178.213:~/terra/IoT/test-results/ ~/IoT/test-results/
|
||||||
```
|
```
|
||||||
|
|
||||||
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
**Никогда не разбираться с результатами по памяти / буферу / чату. Только лог.**
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
.terraform/
|
.terraform/
|
||||||
*.tfstate
|
*.tfstate
|
||||||
*.tfstate.*
|
*.tfstate.*
|
||||||
|
*.tfvars
|
||||||
|
|
||||||
# Go
|
# Go
|
||||||
bin/
|
bin/
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"fission-console/internal/api"
|
"fission-console/internal/api"
|
||||||
"fission-console/internal/auth"
|
"fission-console/internal/auth"
|
||||||
"fission-console/internal/billing"
|
"fission-console/internal/billing"
|
||||||
|
"fission-console/internal/stats"
|
||||||
|
|
||||||
"k8s.io/client-go/dynamic"
|
"k8s.io/client-go/dynamic"
|
||||||
"k8s.io/client-go/kubernetes"
|
"k8s.io/client-go/kubernetes"
|
||||||
@@ -50,6 +51,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
billingStore := billing.NewStore()
|
billingStore := billing.NewStore()
|
||||||
|
statsProvider := stats.NewProvider()
|
||||||
|
|
||||||
srv := api.NewServer(api.Config{
|
srv := api.NewServer(api.Config{
|
||||||
Dyn: dyn,
|
Dyn: dyn,
|
||||||
@@ -68,6 +70,7 @@ func main() {
|
|||||||
LLMKey: os.Getenv("FISSION_LLM_KEY"),
|
LLMKey: os.Getenv("FISSION_LLM_KEY"),
|
||||||
// --- end ai/ask feature ---
|
// --- end ai/ask feature ---
|
||||||
Billing: billingStore,
|
Billing: billingStore,
|
||||||
|
Stats: statsProvider,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Запускаем фоновые горутины: reaper истёкших функций
|
// Запускаем фоновые горутины: reaper истёкших функций
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ rules:
|
|||||||
- apiGroups: [""]
|
- apiGroups: [""]
|
||||||
resources: ["pods/log"]
|
resources: ["pods/log"]
|
||||||
verbs: ["get"]
|
verbs: ["get"]
|
||||||
|
- apiGroups: [""]
|
||||||
|
resources: ["secrets"]
|
||||||
|
verbs: ["get", "list", "create", "delete"]
|
||||||
- apiGroups: ["apps"]
|
- apiGroups: ["apps"]
|
||||||
resources: ["deployments"]
|
resources: ["deployments"]
|
||||||
verbs: ["get", "list", "update", "patch"]
|
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
||||||
- apiGroups: ["fission.io"]
|
- apiGroups: ["fission.io"]
|
||||||
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
resources: ["environments", "packages", "functions", "httptriggers", "timetriggers"]
|
||||||
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
verbs: ["get", "list", "create", "update", "patch", "delete"]
|
||||||
@@ -55,7 +58,7 @@ spec:
|
|||||||
serviceAccountName: fission-console
|
serviceAccountName: fission-console
|
||||||
containers:
|
containers:
|
||||||
- name: console
|
- name: console
|
||||||
image: naeel/fission-console:v1.3.86
|
image: naeel/fission-console:v1.3.88
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 8090
|
- containerPort: 8090
|
||||||
@@ -84,6 +87,14 @@ spec:
|
|||||||
value: "http://storagesvc.fission.svc.cluster.local"
|
value: "http://storagesvc.fission.svc.cluster.local"
|
||||||
- name: BILLING_DSN
|
- name: BILLING_DSN
|
||||||
value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb"
|
value: "postgres://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb"
|
||||||
|
- name: GRAFANA_INTERNAL_URL
|
||||||
|
value: "http://grafana.grafana.svc.cluster.local:3000"
|
||||||
|
- name: GRAFANA_PUBLIC_URL
|
||||||
|
value: "https://fission.kube5s.ru/grafana"
|
||||||
|
- name: GRAFANA_ADMIN_USER
|
||||||
|
value: "admin"
|
||||||
|
- name: GRAFANA_ADMIN_PASS
|
||||||
|
value: "GrafanaAdmin2026!"
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /health
|
path: /health
|
||||||
|
|||||||
@@ -181,6 +181,13 @@ func (s *Server) handleAuth(w http.ResponseWriter, r *http.Request) {
|
|||||||
fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr)
|
fmt.Printf("handleAuth: ensureUserNS %s: %v\n", ns, ensureErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Провизируем Grafana Org для этого namespace (fire-and-forget, идемпотентно).
|
||||||
|
go func() {
|
||||||
|
if err := s.stats.EnsureOrgForNamespace(context.Background(), ns, identity.Email); err != nil {
|
||||||
|
fmt.Printf("handleAuth: EnsureOrgForNamespace %s: %v\n", ns, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email})
|
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "env": env, "namespace": ns, "email": identity.Email})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// kwtriggers.go — CRUD хендлеры для KubernetesWatchTrigger (Fission KW Trigger).
|
||||||
|
//
|
||||||
|
// РЕШЕНИЕ ПО АРХИТЕКТУРЕ (2026-05-11):
|
||||||
|
// KubernetesWatchTrigger позволяет вызывать функцию при изменении K8s объектов.
|
||||||
|
// spec.type — тип ресурса: Pod, Service, Deployment, ConfigMap, и т.д.
|
||||||
|
// spec.namespace — namespace для слежения (по умолчанию = namespace пользователя)
|
||||||
|
// spec.labelselector — label selector в формате "key=value,key2=value2"
|
||||||
|
// spec.functionref — ссылка на функцию
|
||||||
|
//
|
||||||
|
// ОСОБЕННОСТИ:
|
||||||
|
// - Fission kubewatcher компонент должен быть задеплоен.
|
||||||
|
// - namespace в spec — это WATCHED namespace (не namespace триггера).
|
||||||
|
// Для безопасности ограничиваем: только namespace пользователя или пустое (тогда = userNS).
|
||||||
|
// - labelselector опционален, "" = смотрим на все ресурсы типа resourceType в namespace.
|
||||||
|
//
|
||||||
|
// ОШИБКИ В ПРОЦЕССЕ:
|
||||||
|
// - (нет, первая реализация)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/fission"
|
||||||
|
"fission-console/internal/model"
|
||||||
|
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validKWResourceTypes — поддерживаемые типы ресурсов для слежения.
|
||||||
|
// Расширяемо — это не ограничение CRD, просто UI-валидация.
|
||||||
|
var validKWResourceTypes = map[string]struct{}{
|
||||||
|
"pod": {},
|
||||||
|
"service": {},
|
||||||
|
"deployment": {},
|
||||||
|
"configmap": {},
|
||||||
|
"secret": {},
|
||||||
|
"namespace": {},
|
||||||
|
"replicaset": {},
|
||||||
|
"statefulset": {},
|
||||||
|
"daemonset": {},
|
||||||
|
"job": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleKWTriggersRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
s.handleList(fission.KWTrigGVR)(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
s.handleCreateKWTrigger(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleKWTriggersAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/console/api/kwtriggers/")
|
||||||
|
path = strings.TrimPrefix(path, "/api/kwtriggers/")
|
||||||
|
name := strings.Trim(path, "/")
|
||||||
|
if name == "" || strings.Contains(name, "/") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
s.handleGetKWTrigger(w, r, name)
|
||||||
|
case http.MethodDelete:
|
||||||
|
s.handleDeleteKWTrigger(w, r, name)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreateKWTrigger(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.CreateKWTriggerRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("decode request: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := s.userNS(r)
|
||||||
|
|
||||||
|
// Если namespace не задан — используем namespace пользователя
|
||||||
|
if strings.TrimSpace(req.Namespace) == "" {
|
||||||
|
req.Namespace = ns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Безопасность: нельзя смотреть за чужим namespace
|
||||||
|
if req.Namespace != ns {
|
||||||
|
writeJSONError(w, http.StatusForbidden, "can only watch your own namespace")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateKWTriggerRequest(req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Проверяем что функция существует
|
||||||
|
if _, err := s.dyn.Resource(fission.FunctionGVR).Namespace(ns).Get(ctx, req.FunctionName, metav1.GetOptions{}); err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("function %q not found", req.FunctionName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("get function: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := buildKWTriggerObject(ns, req)
|
||||||
|
created, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(ns).Create(ctx, obj, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
writeJSONError(w, http.StatusConflict, fmt.Sprintf("kwtrigger %q already exists", req.Name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("create kwtrigger: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusCreated, kwTriggerResponse(created))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGetKWTrigger(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
obj, err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
writeJSONError(w, status, fmt.Sprintf("get kwtrigger %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, kwTriggerResponse(obj))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteKWTrigger(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := s.dyn.Resource(fission.KWTrigGVR).Namespace(s.userNS(r)).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
|
||||||
|
status := http.StatusBadGateway
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
status = http.StatusNotFound
|
||||||
|
}
|
||||||
|
writeJSONError(w, status, fmt.Sprintf("delete kwtrigger %q: %v", name, err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Вспомогательные функции ---
|
||||||
|
|
||||||
|
func validateKWTriggerRequest(req model.CreateKWTriggerRequest) error {
|
||||||
|
if strings.TrimSpace(req.Name) == "" {
|
||||||
|
return fmt.Errorf("name is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.FunctionName) == "" {
|
||||||
|
return fmt.Errorf("functionName is required")
|
||||||
|
}
|
||||||
|
rt := strings.ToLower(strings.TrimSpace(req.ResourceType))
|
||||||
|
if _, ok := validKWResourceTypes[rt]; !ok {
|
||||||
|
return fmt.Errorf("resourceType must be one of: Pod, Service, Deployment, ConfigMap, Secret, Namespace, ReplicaSet, StatefulSet, DaemonSet, Job")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildKWTriggerObject(ns string, req model.CreateKWTriggerRequest) *unstructured.Unstructured {
|
||||||
|
// Fission ожидает capitalize: Pod, Service, Deployment
|
||||||
|
resourceType := capitalize(strings.TrimSpace(req.ResourceType))
|
||||||
|
|
||||||
|
spec := map[string]any{
|
||||||
|
"type": resourceType,
|
||||||
|
"namespace": req.Namespace,
|
||||||
|
"functionref": map[string]any{
|
||||||
|
"type": "name",
|
||||||
|
"name": req.FunctionName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if req.LabelSelector != "" {
|
||||||
|
spec["labelselector"] = req.LabelSelector
|
||||||
|
}
|
||||||
|
|
||||||
|
return &unstructured.Unstructured{Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "KubernetesWatchTrigger",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": req.Name,
|
||||||
|
"namespace": ns,
|
||||||
|
},
|
||||||
|
"spec": spec,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func kwTriggerResponse(obj *unstructured.Unstructured) map[string]any {
|
||||||
|
spec, _ := obj.Object["spec"].(map[string]any)
|
||||||
|
if spec == nil {
|
||||||
|
spec = map[string]any{}
|
||||||
|
}
|
||||||
|
fnref, _ := spec["functionref"].(map[string]any)
|
||||||
|
fnName := ""
|
||||||
|
if fnref != nil {
|
||||||
|
fnName, _ = fnref["name"].(string)
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": obj.GetName(),
|
||||||
|
"namespace": obj.GetNamespace(),
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"resourceType": spec["type"],
|
||||||
|
"namespace": spec["namespace"],
|
||||||
|
"labelSelector": spec["labelselector"],
|
||||||
|
"functionName": fnName,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// capitalize приводит первый символ к верхнему регистру, остальное без изменений.
|
||||||
|
// "pod" → "Pod", "deployment" → "Deployment"
|
||||||
|
func capitalize(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return strings.ToUpper(s[:1]) + strings.ToLower(s[1:])
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// mqtriggers.go — CRUD для MQ-триггеров через K8s Deployment + Secret.
|
||||||
|
//
|
||||||
|
// АРХИТЕКТУРА (2026-05-11):
|
||||||
|
// Вместо Fission MessageQueueTrigger CRD (требует mqtrigger компонент Kafka/NATS)
|
||||||
|
// Console деплоит собственный sqs-consumer Deployment в namespace пользователя.
|
||||||
|
//
|
||||||
|
// При CREATE:
|
||||||
|
// 1. Создаём K8s Secret (sqs-mq-<name>) с SQS credentials
|
||||||
|
// 2. Создаём K8s Deployment (mq-<name>) с образом naeel/sqs-consumer:v1.0
|
||||||
|
// FUNCTION_URL = http://router.fission.svc.cluster.local/<functionName>
|
||||||
|
// Лейблы: app.kubernetes.io/managed-by=fission-console, component=mq-trigger
|
||||||
|
//
|
||||||
|
// При LIST: deployments -n <ns> -l component=mq-trigger
|
||||||
|
// При DELETE: удаляем Deployment + Secret
|
||||||
|
//
|
||||||
|
// ИЗМЕНЕНИЯ:
|
||||||
|
// v1 — использовал Fission MQ CRD (компонент отсутствует в кластере)
|
||||||
|
// v2 — K8s Deployment + наш sqs-consumer образ
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"fission-console/internal/model"
|
||||||
|
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sqsConsumerImage = "naeel/sqs-consumer:v1.0"
|
||||||
|
sqsDefaultEndpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
|
||||||
|
fissionRouterBase = "http://router.fission.svc.cluster.local"
|
||||||
|
mqTriggerLabelKey = "component"
|
||||||
|
mqTriggerLabelVal = "mq-trigger"
|
||||||
|
mqManagedByLabel = "app.kubernetes.io/managed-by"
|
||||||
|
mqManagedByVal = "fission-console"
|
||||||
|
)
|
||||||
|
|
||||||
|
func mqSecretName(name string) string { return "sqs-mq-" + name }
|
||||||
|
func mqDeployName(name string) string { return "mq-" + name }
|
||||||
|
|
||||||
|
// ── HTTP хендлеры ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) handleMQTriggersRoot(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
s.handleListMQTriggers(w, r)
|
||||||
|
case http.MethodPost:
|
||||||
|
s.handleCreateMQTrigger(w, r)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleMQTriggersAction(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := strings.TrimPrefix(r.URL.Path, "/console/api/mqtriggers/")
|
||||||
|
name := strings.Trim(path, "/")
|
||||||
|
if name == "" || strings.Contains(name, "/") {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodDelete:
|
||||||
|
s.handleDeleteMQTrigger(w, r, name)
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LIST ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) handleListMQTriggers(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ns := s.userNS(r)
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
labelSel := fmt.Sprintf("%s=%s,%s=%s", mqManagedByLabel, mqManagedByVal, mqTriggerLabelKey, mqTriggerLabelVal)
|
||||||
|
deployList, err := s.kube.AppsV1().Deployments(ns).List(ctx, metav1.ListOptions{LabelSelector: labelSel})
|
||||||
|
if err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, fmt.Sprintf("list mq deployments: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]map[string]any, 0, len(deployList.Items))
|
||||||
|
for i := range deployList.Items {
|
||||||
|
items = append(items, mqDeployToResponse(&deployList.Items[i]))
|
||||||
|
}
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── CREATE ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) handleCreateMQTrigger(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req model.CreateMQTriggerRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, "decode request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := validateMQRequest(req); err != nil {
|
||||||
|
writeJSONError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := s.userNS(r)
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
endpoint := strings.TrimSpace(req.SqsEndpoint)
|
||||||
|
if endpoint == "" {
|
||||||
|
endpoint = sqsDefaultEndpoint
|
||||||
|
}
|
||||||
|
functionURL := fissionRouterBase + "/" + req.FunctionName
|
||||||
|
secretName := mqSecretName(req.Name)
|
||||||
|
deployName := mqDeployName(req.Name)
|
||||||
|
|
||||||
|
// 1. Secret с SQS credentials
|
||||||
|
secret := &corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: secretName,
|
||||||
|
Namespace: ns,
|
||||||
|
Labels: map[string]string{
|
||||||
|
mqManagedByLabel: mqManagedByVal,
|
||||||
|
mqTriggerLabelKey: mqTriggerLabelVal,
|
||||||
|
"mq-trigger-name": req.Name,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
StringData: map[string]string{
|
||||||
|
"SQS_ACCESS_KEY": req.AccessKey,
|
||||||
|
"SQS_SECRET_KEY": req.SecretKey,
|
||||||
|
"SQS_ENDPOINT": endpoint,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := s.kube.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
|
||||||
|
if apierrors.IsAlreadyExists(err) {
|
||||||
|
writeJSONError(w, http.StatusConflict, fmt.Sprintf("mq trigger %q already exists", req.Name))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSONError(w, http.StatusBadGateway, "create secret: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Deployment (sqs-consumer)
|
||||||
|
replicas := int32(1)
|
||||||
|
deploy := &appsv1.Deployment{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: deployName,
|
||||||
|
Namespace: ns,
|
||||||
|
Labels: map[string]string{
|
||||||
|
mqManagedByLabel: mqManagedByVal,
|
||||||
|
mqTriggerLabelKey: mqTriggerLabelVal,
|
||||||
|
"mq-trigger-name": req.Name,
|
||||||
|
},
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"fission-console/mq-trigger-name": req.Name,
|
||||||
|
"fission-console/function": req.FunctionName,
|
||||||
|
"fission-console/queue": req.Queue,
|
||||||
|
"fission-console/sqs-endpoint": endpoint,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: appsv1.DeploymentSpec{
|
||||||
|
Replicas: &replicas,
|
||||||
|
Selector: &metav1.LabelSelector{
|
||||||
|
MatchLabels: map[string]string{"mq-trigger-name": req.Name},
|
||||||
|
},
|
||||||
|
Template: corev1.PodTemplateSpec{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Labels: map[string]string{
|
||||||
|
"mq-trigger-name": req.Name,
|
||||||
|
mqTriggerLabelKey: mqTriggerLabelVal,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{{
|
||||||
|
Name: "sqs-consumer",
|
||||||
|
Image: sqsConsumerImage,
|
||||||
|
ImagePullPolicy: corev1.PullAlways,
|
||||||
|
Env: []corev1.EnvVar{
|
||||||
|
{Name: "SQS_QUEUE_NAME", Value: req.Queue},
|
||||||
|
{Name: "SQS_REGION", Value: "us-east-1"},
|
||||||
|
{Name: "FUNCTION_URL", Value: functionURL},
|
||||||
|
{Name: "POLL_INTERVAL", Value: "5"},
|
||||||
|
{Name: "MAX_MESSAGES", Value: "1"},
|
||||||
|
{Name: "MAX_RETRIES", Value: "3"},
|
||||||
|
},
|
||||||
|
EnvFrom: []corev1.EnvFromSource{{
|
||||||
|
SecretRef: &corev1.SecretEnvSource{
|
||||||
|
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
Resources: corev1.ResourceRequirements{
|
||||||
|
Limits: corev1.ResourceList{
|
||||||
|
corev1.ResourceCPU: resource.MustParse("50m"),
|
||||||
|
corev1.ResourceMemory: resource.MustParse("32Mi"),
|
||||||
|
},
|
||||||
|
Requests: corev1.ResourceList{
|
||||||
|
corev1.ResourceCPU: resource.MustParse("10m"),
|
||||||
|
corev1.ResourceMemory: resource.MustParse("16Mi"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := s.kube.AppsV1().Deployments(ns).Create(ctx, deploy, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
_ = s.kube.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{})
|
||||||
|
writeJSONError(w, http.StatusBadGateway, "create deployment: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeAnyJSON(w, http.StatusCreated, mqDeployToResponse(created))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DELETE ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *Server) handleDeleteMQTrigger(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
ns := s.userNS(r)
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
dErr := s.kube.AppsV1().Deployments(ns).Delete(ctx, mqDeployName(name), metav1.DeleteOptions{})
|
||||||
|
sErr := s.kube.CoreV1().Secrets(ns).Delete(ctx, mqSecretName(name), metav1.DeleteOptions{})
|
||||||
|
|
||||||
|
if dErr != nil && !apierrors.IsNotFound(dErr) {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, "delete deployment: "+dErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sErr != nil && !apierrors.IsNotFound(sErr) {
|
||||||
|
writeJSONError(w, http.StatusBadGateway, "delete secret: "+sErr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeAnyJSON(w, http.StatusOK, map[string]any{"deleted": true, "name": name})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Вспомогательные ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func validateMQRequest(req model.CreateMQTriggerRequest) error {
|
||||||
|
if strings.TrimSpace(req.Name) == "" {
|
||||||
|
return fmt.Errorf("name is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.FunctionName) == "" {
|
||||||
|
return fmt.Errorf("functionName is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.Queue) == "" {
|
||||||
|
return fmt.Errorf("queue is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.AccessKey) == "" {
|
||||||
|
return fmt.Errorf("accessKey is required")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(req.SecretKey) == "" {
|
||||||
|
return fmt.Errorf("secretKey is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func mqDeployToResponse(d *appsv1.Deployment) map[string]any {
|
||||||
|
ann := d.Annotations
|
||||||
|
if ann == nil {
|
||||||
|
ann = map[string]string{}
|
||||||
|
}
|
||||||
|
triggerName := ann["fission-console/mq-trigger-name"]
|
||||||
|
if triggerName == "" {
|
||||||
|
triggerName = strings.TrimPrefix(d.Name, "mq-")
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"name": triggerName,
|
||||||
|
"deployName": d.Name,
|
||||||
|
"functionName": ann["fission-console/function"],
|
||||||
|
"queue": ann["fission-console/queue"],
|
||||||
|
"sqsEndpoint": ann["fission-console/sqs-endpoint"],
|
||||||
|
"ready": d.Status.ReadyReplicas > 0,
|
||||||
|
"replicas": d.Status.ReadyReplicas,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"fission-console/internal/billing"
|
"fission-console/internal/billing"
|
||||||
"fission-console/internal/cloud"
|
"fission-console/internal/cloud"
|
||||||
"fission-console/internal/fission"
|
"fission-console/internal/fission"
|
||||||
|
"fission-console/internal/stats"
|
||||||
"fission-console/ui"
|
"fission-console/ui"
|
||||||
|
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
@@ -63,6 +64,9 @@ type Server struct {
|
|||||||
|
|
||||||
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
|
// billing — слой записи статистики вызовов. NoopStore если BILLING_DSN не задан.
|
||||||
billing billing.Store
|
billing billing.Store
|
||||||
|
|
||||||
|
// stats — аналитический слой (Grafana Organizations). NoopProvider если не настроен.
|
||||||
|
stats stats.StatsProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config содержит все параметры для создания Server.
|
// Config содержит все параметры для создания Server.
|
||||||
@@ -80,7 +84,8 @@ type Config struct {
|
|||||||
Authenticator auth.Authenticator // слой аутентификации
|
Authenticator auth.Authenticator // слой аутентификации
|
||||||
LLMUrl string
|
LLMUrl string
|
||||||
LLMKey string
|
LLMKey string
|
||||||
Billing billing.Store // слой статистики (NoopStore если не задан)
|
Billing billing.Store // слой статистики (NoopStore если не задан)
|
||||||
|
Stats stats.StatsProvider // аналитика (NoopProvider если не настроен)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
|
// NewServer создаёт и настраивает HTTP Server со всеми зависимостями.
|
||||||
@@ -102,6 +107,7 @@ func NewServer(cfg Config) *Server {
|
|||||||
llmKey: cfg.LLMKey,
|
llmKey: cfg.LLMKey,
|
||||||
nsManager: cloud.NewNSManager(cfg.Dyn),
|
nsManager: cloud.NewNSManager(cfg.Dyn),
|
||||||
billing: cfg.Billing,
|
billing: cfg.Billing,
|
||||||
|
stats: cfg.Stats,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,8 +169,13 @@ func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
|
mux.HandleFunc("/console/api/httptriggers", auth(s.handleList(fission.HTTPTrigGVR)))
|
||||||
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
|
mux.HandleFunc("/console/api/timetriggers", auth(s.handleTimeTriggersRoot))
|
||||||
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
|
mux.HandleFunc("/console/api/timetriggers/", auth(s.handleTimeTriggersAction))
|
||||||
|
mux.HandleFunc("/console/api/mqtriggers", auth(s.handleMQTriggersRoot))
|
||||||
|
mux.HandleFunc("/console/api/mqtriggers/", auth(s.handleMQTriggersAction))
|
||||||
|
mux.HandleFunc("/console/api/kwtriggers", auth(s.handleKWTriggersRoot))
|
||||||
|
mux.HandleFunc("/console/api/kwtriggers/", auth(s.handleKWTriggersAction))
|
||||||
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
|
mux.HandleFunc("/console/api/ns/status", auth(s.handleNSStatus))
|
||||||
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
|
mux.HandleFunc("/console/api/ns/debug", auth(s.handleNSDebug))
|
||||||
|
mux.HandleFunc("/console/api/stats/dashboard-url", auth(s.handleStatsDashboard))
|
||||||
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
|
mux.HandleFunc("/console/api/ai/check", auth(s.handleAICheck))
|
||||||
mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive))
|
mux.HandleFunc("/console/api/ai/lint-archive", auth(s.handleLintArchive))
|
||||||
mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive))
|
mux.HandleFunc("/console/api/ai/explain-archive", auth(s.handleExplainArchive))
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// handleStatsDashboard GET /console/api/stats/dashboard-url
|
||||||
|
// Возвращает публичный URL дашборда Grafana для текущего namespace пользователя.
|
||||||
|
// Если аналитика не настроена — возвращает {"url":""}.
|
||||||
|
func (s *Server) handleStatsDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeJSONError(w, http.StatusMethodNotAllowed, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := s.userNS(r)
|
||||||
|
url := s.stats.DashboardURL(r.Context(), ns)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
"url": url,
|
||||||
|
"namespace": ns,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ var (
|
|||||||
FunctionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
|
FunctionGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "functions"}
|
||||||
HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
|
HTTPTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "httptriggers"}
|
||||||
TimeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
|
TimeTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "timetriggers"}
|
||||||
|
MQTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "messagequeuetriggers"}
|
||||||
|
KWTrigGVR = schema.GroupVersionResource{Group: "fission.io", Version: "v1", Resource: "kuberneteswatchtriggers"}
|
||||||
|
|
||||||
NamespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
|
NamespaceGVR = schema.GroupVersionResource{Group: "", Version: "v1", Resource: "namespaces"}
|
||||||
DeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
|
DeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}
|
||||||
|
|||||||
@@ -26,6 +26,28 @@ type CreateTimeTriggerRequest struct {
|
|||||||
SubPath string `json:"subpath"`
|
SubPath string `json:"subpath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CreateMQTriggerRequest — тело POST /console/api/mqtriggers.
|
||||||
|
// Архитектура: Console создаёт K8s Deployment (sqs-consumer) + Secret с credentials
|
||||||
|
// в namespace пользователя. sqs-consumer поллит shared-sqs → вызывает Fission-функцию.
|
||||||
|
type CreateMQTriggerRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
FunctionName string `json:"functionName"`
|
||||||
|
Queue string `json:"queue"` // имя очереди в SQS
|
||||||
|
SqsEndpoint string `json:"sqsEndpoint"` // URL SQS сервиса (default: internal shared-sqs)
|
||||||
|
AccessKey string `json:"accessKey"` // SQS access key тенанта
|
||||||
|
SecretKey string `json:"secretKey"` // SQS secret key тенанта
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateKWTriggerRequest — тело POST /console/api/kwtriggers.
|
||||||
|
// Документация полей: kubectl get crd kuberneteswatchtriggers.fission.io -o json
|
||||||
|
type CreateKWTriggerRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
FunctionName string `json:"functionName"`
|
||||||
|
ResourceType string `json:"resourceType"` // Pod, Service, Deployment и т.д.
|
||||||
|
Namespace string `json:"namespace"` // пустое = namespace пользователя
|
||||||
|
LabelSelector string `json:"labelSelector"` // "app=foo" или "" для всех
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateCodeRequest — тело PUT /console/api/functions/:name/code.
|
// UpdateCodeRequest — тело PUT /console/api/functions/:name/code.
|
||||||
type UpdateCodeRequest struct {
|
type UpdateCodeRequest struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewProvider создаёт StatsProvider из переменных окружения.
|
||||||
|
//
|
||||||
|
// Переменные:
|
||||||
|
// - GRAFANA_INTERNAL_URL — внутренний URL (http://grafana.grafana.svc.cluster.local:3000)
|
||||||
|
// - GRAFANA_PUBLIC_URL — публичный URL для ссылок (https://fission.kube5s.ru/grafana)
|
||||||
|
// - GRAFANA_ADMIN_USER — имя admin (default: "admin")
|
||||||
|
// - GRAFANA_ADMIN_PASS — пароль admin
|
||||||
|
func NewProvider() StatsProvider {
|
||||||
|
internalURL := strings.TrimSpace(os.Getenv("GRAFANA_INTERNAL_URL"))
|
||||||
|
if internalURL == "" {
|
||||||
|
log.Printf("stats: GRAFANA_INTERNAL_URL not set — using NoopProvider")
|
||||||
|
return NoopProvider{}
|
||||||
|
}
|
||||||
|
|
||||||
|
publicURL := strings.TrimSpace(os.Getenv("GRAFANA_PUBLIC_URL"))
|
||||||
|
if publicURL == "" {
|
||||||
|
publicURL = "https://fission.kube5s.ru/grafana"
|
||||||
|
}
|
||||||
|
|
||||||
|
adminUser := strings.TrimSpace(os.Getenv("GRAFANA_ADMIN_USER"))
|
||||||
|
if adminUser == "" {
|
||||||
|
adminUser = "admin"
|
||||||
|
}
|
||||||
|
adminPass := os.Getenv("GRAFANA_ADMIN_PASS")
|
||||||
|
|
||||||
|
log.Printf("stats: GrafanaProvider internalURL=%s publicURL=%s user=%s", internalURL, publicURL, adminUser)
|
||||||
|
return NewGrafanaProvider(internalURL, publicURL, adminUser, adminPass)
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GrafanaProvider реализует StatsProvider через Grafana HTTP API.
|
||||||
|
//
|
||||||
|
// Для каждого namespace создаётся изолированная Grafana Organization:
|
||||||
|
// - PostgreSQL datasource (тот же DSN, uid="fission-user-pg")
|
||||||
|
// - Dashboard с hardcoded WHERE namespace='...'
|
||||||
|
// - Public Dashboard (без логина) → accessToken
|
||||||
|
//
|
||||||
|
// Потокобезопасен: sync.RWMutex + per-namespace singleflight.
|
||||||
|
type GrafanaProvider struct {
|
||||||
|
internalURL string // http://grafana.grafana.svc.cluster.local:3000
|
||||||
|
publicURL string // https://fission.kube5s.ru/grafana
|
||||||
|
adminUser string
|
||||||
|
adminPass string
|
||||||
|
http *http.Client
|
||||||
|
|
||||||
|
mu sync.RWMutex
|
||||||
|
tokens map[string]string // namespace → publicDashboardAccessToken
|
||||||
|
orgIDs map[string]int64 // namespace → grafana orgId
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGrafanaProvider создаёт GrafanaProvider.
|
||||||
|
func NewGrafanaProvider(internalURL, publicURL, adminUser, adminPass string) *GrafanaProvider {
|
||||||
|
return &GrafanaProvider{
|
||||||
|
internalURL: strings.TrimRight(internalURL, "/"),
|
||||||
|
publicURL: strings.TrimRight(publicURL, "/"),
|
||||||
|
adminUser: adminUser,
|
||||||
|
adminPass: adminPass,
|
||||||
|
http: &http.Client{Timeout: 20 * time.Second},
|
||||||
|
tokens: make(map[string]string),
|
||||||
|
orgIDs: make(map[string]int64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureOrgForNamespace идемпотентно создаёт Grafana Org + datasource + dashboard + public link.
|
||||||
|
func (g *GrafanaProvider) EnsureOrgForNamespace(ctx context.Context, namespace, email string) error {
|
||||||
|
// Быстрый путь: уже провизировано в этом процессе
|
||||||
|
g.mu.RLock()
|
||||||
|
_, cached := g.tokens[namespace]
|
||||||
|
g.mu.RUnlock()
|
||||||
|
if cached {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 1: получить или создать Org
|
||||||
|
orgID, err := g.getOrCreateOrg(ctx, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("getOrCreateOrg(%s): %w", namespace, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 2: создать datasource в этой Org (идемпотентно)
|
||||||
|
if err := g.ensureDatasource(ctx, orgID); err != nil {
|
||||||
|
log.Printf("stats: ensureDatasource org=%d ns=%s: %v", orgID, namespace, err)
|
||||||
|
// не фатально — dashboard может не работать но org создана
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 3: создать dashboard с hardcoded namespace (идемпотентно)
|
||||||
|
dashUID, err := g.ensureDashboard(ctx, orgID, namespace)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ensureDashboard org=%d ns=%s: %w", orgID, namespace, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 4: получить или создать public dashboard → accessToken
|
||||||
|
token, err := g.ensurePublicDashboard(ctx, orgID, dashUID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ensurePublicDashboard org=%d dash=%s: %w", orgID, dashUID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кэшируем
|
||||||
|
g.mu.Lock()
|
||||||
|
g.tokens[namespace] = token
|
||||||
|
g.orgIDs[namespace] = orgID
|
||||||
|
g.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf("stats: org provisioned ns=%s orgId=%d publicToken=%s...", namespace, orgID, token[:8])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardURL возвращает публичный URL или "" если ещё не провизировано.
|
||||||
|
func (g *GrafanaProvider) DashboardURL(ctx context.Context, namespace string) string {
|
||||||
|
// Сначала пробуем из кэша
|
||||||
|
g.mu.RLock()
|
||||||
|
token, ok := g.tokens[namespace]
|
||||||
|
g.mu.RUnlock()
|
||||||
|
if ok && token != "" {
|
||||||
|
return g.publicURL + "/public-dashboards/" + token
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кэш промах (после перезапуска сервера) — провизируем заново
|
||||||
|
if err := g.EnsureOrgForNamespace(ctx, namespace, ""); err != nil {
|
||||||
|
log.Printf("stats: DashboardURL re-provision ns=%s: %v", namespace, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
g.mu.RLock()
|
||||||
|
token = g.tokens[namespace]
|
||||||
|
g.mu.RUnlock()
|
||||||
|
if token == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return g.publicURL + "/public-dashboards/" + token
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grafana API helpers ---
|
||||||
|
|
||||||
|
// getOrCreateOrg возвращает orgId существующей или создаёт новую Org.
|
||||||
|
func (g *GrafanaProvider) getOrCreateOrg(ctx context.Context, namespace string) (int64, error) {
|
||||||
|
// Проверяем кэш orgIDs
|
||||||
|
g.mu.RLock()
|
||||||
|
if id, ok := g.orgIDs[namespace]; ok {
|
||||||
|
g.mu.RUnlock()
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
g.mu.RUnlock()
|
||||||
|
|
||||||
|
// GET /api/orgs/name/{namespace}
|
||||||
|
resp, body, err := g.grafanaRequest(ctx, http.MethodGet, "/api/orgs/name/"+namespace, 0, nil)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
var org struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &org); err != nil {
|
||||||
|
return 0, fmt.Errorf("parse org: %w", err)
|
||||||
|
}
|
||||||
|
return org.ID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Org не найдена — создаём
|
||||||
|
payload := map[string]string{"name": namespace}
|
||||||
|
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, "/api/orgs", 0, payload)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return 0, fmt.Errorf("create org status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
OrgID int64 `json:"orgId"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &created); err != nil {
|
||||||
|
return 0, fmt.Errorf("parse create org: %w", err)
|
||||||
|
}
|
||||||
|
return created.OrgID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDatasource создаёт PostgreSQL datasource в org (uid="fission-user-pg").
|
||||||
|
// Идемпотентен: 409 Conflict считается успехом.
|
||||||
|
func (g *GrafanaProvider) ensureDatasource(ctx context.Context, orgID int64) error {
|
||||||
|
// Проверяем есть ли уже datasource в этой org
|
||||||
|
resp, _, err := g.grafanaRequest(ctx, http.MethodGet, "/api/datasources/uid/fission-user-pg", orgID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return nil // уже есть
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем DSN из уже существующего datasource в Org 1 (uid=fission-pg)
|
||||||
|
_, body, err := g.grafanaRequest(ctx, http.MethodGet, "/api/datasources/uid/fission-pg", 1, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get main datasource: %w", err)
|
||||||
|
}
|
||||||
|
var ds struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
JSONData json.RawMessage `json:"jsonData"`
|
||||||
|
SecureJSONData struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
} `json:"secureJsonData"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &ds); err != nil {
|
||||||
|
return fmt.Errorf("parse main datasource: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём копию datasource в новой Org
|
||||||
|
payload := map[string]any{
|
||||||
|
"name": "fission-pg",
|
||||||
|
"type": "postgres",
|
||||||
|
"uid": "fission-user-pg",
|
||||||
|
"url": ds.URL,
|
||||||
|
"access": "proxy",
|
||||||
|
"jsonData": map[string]any{
|
||||||
|
"sslmode": "disable",
|
||||||
|
"postgresVersion": 1700,
|
||||||
|
"timescaledb": false,
|
||||||
|
},
|
||||||
|
"secureJsonData": ds.SecureJSONData,
|
||||||
|
}
|
||||||
|
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, "/api/datasources", orgID, payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusConflict {
|
||||||
|
return nil // уже существует
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return fmt.Errorf("create datasource status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureDashboard создаёт/обновляет user-дашборд в org.
|
||||||
|
// Возвращает uid дашборда.
|
||||||
|
func (g *GrafanaProvider) ensureDashboard(ctx context.Context, orgID int64, namespace string) (string, error) {
|
||||||
|
const dashUID = "fission-user-overview"
|
||||||
|
|
||||||
|
// Проверяем существование
|
||||||
|
resp, _, err := g.grafanaRequest(ctx, http.MethodGet, "/api/dashboards/uid/"+dashUID, orgID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return dashUID, nil // уже есть
|
||||||
|
}
|
||||||
|
|
||||||
|
// Импортируем dashboard JSON с hardcoded namespace
|
||||||
|
dashJSON := userDashboardJSON(namespace)
|
||||||
|
payload := map[string]any{
|
||||||
|
"dashboard": json.RawMessage(dashJSON),
|
||||||
|
"overwrite": true,
|
||||||
|
"folderId": 0,
|
||||||
|
}
|
||||||
|
resp, body, err := g.grafanaRequest(ctx, http.MethodPost, "/api/dashboards/db", orgID, payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("import dashboard status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
return dashUID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensurePublicDashboard создаёт public dashboard и возвращает accessToken.
|
||||||
|
// Идемпотентен: если уже существует — возвращает существующий token.
|
||||||
|
func (g *GrafanaProvider) ensurePublicDashboard(ctx context.Context, orgID int64, dashUID string) (string, error) {
|
||||||
|
path := "/api/dashboards/uid/" + dashUID + "/public-dashboards"
|
||||||
|
|
||||||
|
// Проверяем существование
|
||||||
|
resp, body, err := g.grafanaRequest(ctx, http.MethodGet, path, orgID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
var pd struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &pd); err != nil {
|
||||||
|
return "", fmt.Errorf("parse public dashboard: %w", err)
|
||||||
|
}
|
||||||
|
if pd.AccessToken != "" {
|
||||||
|
return pd.AccessToken, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём
|
||||||
|
payload := map[string]any{
|
||||||
|
"isEnabled": true,
|
||||||
|
"annotationsEnabled": false,
|
||||||
|
"timeSelectionEnabled": true,
|
||||||
|
}
|
||||||
|
resp, body, err = g.grafanaRequest(ctx, http.MethodPost, path, orgID, payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return "", fmt.Errorf("create public dashboard status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var pd struct {
|
||||||
|
AccessToken string `json:"accessToken"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &pd); err != nil {
|
||||||
|
return "", fmt.Errorf("parse created public dashboard: %w", err)
|
||||||
|
}
|
||||||
|
if pd.AccessToken == "" {
|
||||||
|
return "", fmt.Errorf("empty accessToken in response: %s", string(body))
|
||||||
|
}
|
||||||
|
return pd.AccessToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// grafanaRequest выполняет HTTP запрос к Grafana API.
|
||||||
|
// orgID > 0 → устанавливает X-Grafana-Org-Id заголовок (thread-safe, без смены контекста).
|
||||||
|
// orgID == 0 → без заголовка (используется Org 1 admin по умолчанию).
|
||||||
|
func (g *GrafanaProvider) grafanaRequest(ctx context.Context, method, path string, orgID int64, payload any) (*http.Response, []byte, error) {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if payload != nil {
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
bodyReader = bytes.NewReader(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, method, g.internalURL+path, bodyReader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("new request: %w", err)
|
||||||
|
}
|
||||||
|
req.SetBasicAuth(g.adminUser, g.adminPass)
|
||||||
|
if payload != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if orgID > 0 {
|
||||||
|
req.Header.Set("X-Grafana-Org-Id", fmt.Sprintf("%d", orgID))
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := g.http.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("do request %s %s: %w", method, path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return resp, nil, fmt.Errorf("read body: %w", err)
|
||||||
|
}
|
||||||
|
return resp, body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// userDashboardJSON генерирует JSON дашборда для конкретного namespace.
|
||||||
|
// Namespace вшит прямо в SQL запросы — без template variables.
|
||||||
|
// Dashboard uid="fission-user-overview" (per-org, без конфликтов между org).
|
||||||
|
func userDashboardJSON(namespace string) string {
|
||||||
|
// Безопасное экранирование namespace для SQL (namespace это sha256 hex — только [a-z0-9-])
|
||||||
|
ns := strings.ReplaceAll(namespace, "'", "''")
|
||||||
|
return fmt.Sprintf(`{
|
||||||
|
"title": "Мои функции — %s",
|
||||||
|
"uid": "fission-user-overview",
|
||||||
|
"tags": ["fission", "user"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"refresh": "1m",
|
||||||
|
"time": {"from": "now-24h", "to": "now"},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1, "title": "Вызовы в час", "type": "timeseries",
|
||||||
|
"gridPos": {"x": 0, "y": 0, "w": 16, "h": 8},
|
||||||
|
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
|
||||||
|
"targets": [{"rawSql": "SELECT date_trunc('hour', started_at) AS time, count(*) AS value, function_name FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1, function_name ORDER BY 1", "format": "time_series", "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2, "title": "Успех vs Ошибки", "type": "piechart",
|
||||||
|
"gridPos": {"x": 16, "y": 0, "w": 8, "h": 8},
|
||||||
|
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
|
||||||
|
"targets": [{"rawSql": "SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN 'success' WHEN status_code = 0 THEN 'event' ELSE 'error' END AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1", "format": "table", "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3, "title": "Топ функций", "type": "bargauge",
|
||||||
|
"gridPos": {"x": 0, "y": 8, "w": 12, "h": 8},
|
||||||
|
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
|
||||||
|
"targets": [{"rawSql": "SELECT function_name AS metric, count(*) AS value FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() GROUP BY 1 ORDER BY 2 DESC LIMIT 10", "format": "table", "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4, "title": "Средняя латентность (ms)", "type": "timeseries",
|
||||||
|
"gridPos": {"x": 12, "y": 8, "w": 12, "h": 8},
|
||||||
|
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
|
||||||
|
"targets": [{"rawSql": "SELECT date_trunc('hour', started_at) AS time, round(avg(duration_ms)) AS avg_ms FROM invocations WHERE namespace = '%s' AND started_at BETWEEN $__timeFrom() AND $__timeTo() AND trigger_type != 'event' GROUP BY 1 ORDER BY 1", "format": "time_series", "refId": "A"}]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5, "title": "Последние события", "type": "table",
|
||||||
|
"gridPos": {"x": 0, "y": 16, "w": 24, "h": 8},
|
||||||
|
"datasource": {"type": "postgres", "uid": "fission-user-pg"},
|
||||||
|
"targets": [{"rawSql": "SELECT started_at AS time, function_name, trigger_type, event_type, status_code, duration_ms, error_msg FROM invocations WHERE namespace = '%s' ORDER BY started_at DESC LIMIT 50", "format": "table", "refId": "A"}]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schemaVersion": 39
|
||||||
|
}`, namespace, ns, ns, ns, ns, ns)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// NoopProvider — заглушка когда GRAFANA_INTERNAL_URL не задан.
|
||||||
|
// Ни на что не влияет, не крашит.
|
||||||
|
type NoopProvider struct{}
|
||||||
|
|
||||||
|
func (NoopProvider) EnsureOrgForNamespace(_ context.Context, _, _ string) error { return nil }
|
||||||
|
func (NoopProvider) DashboardURL(_ context.Context, _ string) string { return "" }
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Package stats — аналитический слой консоли.
|
||||||
|
//
|
||||||
|
// StatsProvider абстрагирует конкретный инструмент (Grafana, Prometheus, etc.).
|
||||||
|
// При смене инструмента достаточно заменить реализацию и переменные окружения.
|
||||||
|
package stats
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// StatsProvider — интерфейс аналитики.
|
||||||
|
// Реализации: GrafanaProvider (GRAFANA_INTERNAL_URL задан), NoopProvider (заглушка).
|
||||||
|
type StatsProvider interface {
|
||||||
|
// EnsureOrgForNamespace идемпотентно создаёт аналитическое пространство
|
||||||
|
// для namespace (Grafana Org + datasource + dashboard + public link).
|
||||||
|
// Вызывается при handleAuth — fire-and-forget горутиной.
|
||||||
|
EnsureOrgForNamespace(ctx context.Context, namespace, email string) error
|
||||||
|
|
||||||
|
// DashboardURL возвращает публичный URL дашборда без логина.
|
||||||
|
// Возвращает "" если аналитика не настроена или provisioning не завершён.
|
||||||
|
DashboardURL(ctx context.Context, namespace string) string
|
||||||
|
}
|
||||||
+74
-2
@@ -25,6 +25,7 @@
|
|||||||
<script src="js/fn-archive.js"></script>
|
<script src="js/fn-archive.js"></script>
|
||||||
<script src="js/ai.js"></script>
|
<script src="js/ai.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
|
<script src="js/mq.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -102,13 +103,14 @@
|
|||||||
<div class="nubes">NUBES</div>
|
<div class="nubes">NUBES</div>
|
||||||
<div class="product">FISSION CONSOLE</div>
|
<div class="product">FISSION CONSOLE</div>
|
||||||
</div>
|
</div>
|
||||||
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.86</div>
|
<div style="font-size:0.65rem; color:var(--text-secondary); margin-left:10px; align-self:center; opacity:0.7;">v1.3.88</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row" style="margin:0;">
|
<div class="row" style="margin:0;">
|
||||||
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
<button class="btn ghost" onclick="reloadAll()">Refresh</button>
|
||||||
<button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
|
<button class="btn" onclick="openCreateCode()">✏️ Из кода</button>
|
||||||
<button class="btn" onclick="openCreateArchive()">📦 Из архива</button>
|
<button class="btn" onclick="openCreateArchive()">📦 Из архива</button>
|
||||||
<button class="btn ghost" onclick="openHelp()">Help</button>
|
<button class="btn ghost" onclick="openHelp()">Help</button>
|
||||||
|
<button class="btn ghost" onclick="openAnalytics()" title="Открыть дашборд Grafana">📊 Аналитика</button>
|
||||||
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
|
<button class="btn ghost" onclick="doLogout()" style="margin-left:8px;">Выход</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,6 +137,10 @@
|
|||||||
<div class="k">Крон-функции</div>
|
<div class="k">Крон-функции</div>
|
||||||
<div id="cron-count" class="v">-</div>
|
<div id="cron-count" class="v">-</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="k">MQ-триггеры</div>
|
||||||
|
<div id="mq-count" class="v">-</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="box">
|
<div class="box">
|
||||||
@@ -173,6 +179,72 @@
|
|||||||
<div id="status" class="status"></div>
|
<div id="status" class="status"></div>
|
||||||
<div class="hint">Изменения применяются напрямую через CRD Fission.</div>
|
<div class="hint">Изменения применяются напрямую через CRD Fission.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MQ-триггеры -->
|
||||||
|
<div class="box">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div style="font-weight:600;">MQ-триггеры</div>
|
||||||
|
<button class="btn" onclick="openCreateMQ()">+ Добавить</button>
|
||||||
|
</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Имя</th>
|
||||||
|
<th>Очередь</th>
|
||||||
|
<th>Функция</th>
|
||||||
|
<th>Endpoint SQS</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="mq-rows">
|
||||||
|
<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Загрузка...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="hint">MQ-триггер поллит очередь SQS и вызывает Fission-функцию при появлении сообщений.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модалка: создать MQ-триггер -->
|
||||||
|
<div id="mq-create-modal" class="modal">
|
||||||
|
<div class="panel" style="width:520px;max-width:96vw;">
|
||||||
|
<h3>📨 Создать MQ-триггер</h3>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Имя триггера</label>
|
||||||
|
<input id="mq-name" placeholder="weather-trigger">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Функция</label>
|
||||||
|
<input id="mq-fn" placeholder="weather-store">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Имя очереди (SQS)</label>
|
||||||
|
<input id="mq-queue" placeholder="weather-raw">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>SQS Endpoint</label>
|
||||||
|
<input id="mq-endpoint" placeholder="http://shared-sqs.shared-sqs.svc.cluster.local:4100">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="field">
|
||||||
|
<label>Access Key</label>
|
||||||
|
<input id="mq-access-key" placeholder="SSAK-...">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>Secret Key</label>
|
||||||
|
<input id="mq-secret-key" type="password" placeholder="...">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="mq-create-error" class="modal-error"></div>
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn ghost" onclick="closeMQCreate()">Отмена</button>
|
||||||
|
<button class="btn" onclick="submitCreateMQ()">Создать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Модалка: создать функцию из кода (prefix cc-) -->
|
<!-- Модалка: создать функцию из кода (prefix cc-) -->
|
||||||
@@ -542,7 +614,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions" style="justify-content:space-between; align-items:center;">
|
<div class="actions" style="justify-content:space-between; align-items:center;">
|
||||||
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.86</span>
|
<span style="font-size:0.75rem; color:var(--text-secondary);">v1.3.88</span>
|
||||||
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
<button class="btn ghost" onclick="closeHelp()">Закрыть</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ async function reloadAll() {
|
|||||||
getJSON(API_BASE + '/timetriggers')
|
getJSON(API_BASE + '/timetriggers')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// MQ-триггеры загружаем параллельно, не блокируем основную таблицу
|
||||||
|
if (typeof loadMQTriggers === 'function') loadMQTriggers();
|
||||||
|
|
||||||
S.envs = envs || [];
|
S.envs = envs || [];
|
||||||
S.fns = fns || [];
|
S.fns = fns || [];
|
||||||
S.httpTriggers = http || [];
|
S.httpTriggers = http || [];
|
||||||
|
|||||||
@@ -93,6 +93,27 @@ function doLogout() {
|
|||||||
showLoginOverlay();
|
showLoginOverlay();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openAnalytics() {
|
||||||
|
var token = localStorage.getItem('auth_token');
|
||||||
|
var env = localStorage.getItem('auth_env') || 'test';
|
||||||
|
if (!token) { alert('Требуется авторизация'); return; }
|
||||||
|
try {
|
||||||
|
var r = await fetch(API_BASE + '/stats/dashboard-url', {
|
||||||
|
headers: { 'X-Auth-Token': token, 'X-Auth-Env': env }
|
||||||
|
});
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
var d = await r.json();
|
||||||
|
if (d.url) {
|
||||||
|
window.open(d.url, '_blank', 'noopener');
|
||||||
|
} else {
|
||||||
|
// Grafana не настроена или org ещё провизируется — ссылка на оператора
|
||||||
|
window.open('/grafana/', '_blank', 'noopener');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Аналитика временно недоступна: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function checkAuth() {
|
function checkAuth() {
|
||||||
var storedToken = localStorage.getItem('auth_token');
|
var storedToken = localStorage.getItem('auth_token');
|
||||||
if (!storedToken) {
|
if (!storedToken) {
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// mq.js — MQ-триггеры (SQS → Fission function)
|
||||||
|
// Архитектура: Console создаёт K8s Deployment + Secret (sqs-consumer) в namespace пользователя.
|
||||||
|
// sqs-consumer поллит SQS очередь → HTTP POST в Fission-функцию → DeleteMessage.
|
||||||
|
// Backend: POST /console/api/mqtriggers, DELETE /console/api/mqtriggers/{name}
|
||||||
|
// v1.3.88
|
||||||
|
|
||||||
|
// ── Загрузка и отрисовка ────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function loadMQTriggers() {
|
||||||
|
try {
|
||||||
|
const data = await apiFetch('/console/api/mqtriggers');
|
||||||
|
renderMQTable(data.items || []);
|
||||||
|
const cnt = document.getElementById('mq-count');
|
||||||
|
if (cnt) cnt.textContent = (data.items || []).length;
|
||||||
|
} catch (e) {
|
||||||
|
renderMQTable([]);
|
||||||
|
const cnt = document.getElementById('mq-count');
|
||||||
|
if (cnt) cnt.textContent = '0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMQTable(items) {
|
||||||
|
const tbody = document.getElementById('mq-rows');
|
||||||
|
if (!tbody) return;
|
||||||
|
if (!items.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" style="color:var(--fg-muted,#888);text-align:center;">Нет MQ-триггеров</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = items.map(t => `
|
||||||
|
<tr>
|
||||||
|
<td>${escHtml(t.name)}</td>
|
||||||
|
<td>${escHtml(t.queue)}</td>
|
||||||
|
<td>${escHtml(t.functionName)}</td>
|
||||||
|
<td style="font-size:0.8em;color:var(--fg-muted,#aaa);">${escHtml(t.sqsEndpoint || '')}</td>
|
||||||
|
<td><span style="color:${t.ready ? '#4a4' : '#a44'}">${t.ready ? '▶ Running' : '◼ Pending'}</span></td>
|
||||||
|
<td>
|
||||||
|
<button class="btn ghost small" onclick="deleteMQTrigger('${escHtml(t.name)}')">Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Создание ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openCreateMQ() {
|
||||||
|
document.getElementById('mq-name').value = '';
|
||||||
|
document.getElementById('mq-fn').value = '';
|
||||||
|
document.getElementById('mq-queue').value = '';
|
||||||
|
document.getElementById('mq-endpoint').value = 'http://shared-sqs.shared-sqs.svc.cluster.local:4100';
|
||||||
|
document.getElementById('mq-access-key').value = '';
|
||||||
|
document.getElementById('mq-secret-key').value = '';
|
||||||
|
document.getElementById('mq-create-error').textContent = '';
|
||||||
|
document.getElementById('mq-create-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMQCreate() {
|
||||||
|
document.getElementById('mq-create-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateMQ() {
|
||||||
|
const name = document.getElementById('mq-name').value.trim();
|
||||||
|
const fnName = document.getElementById('mq-fn').value.trim();
|
||||||
|
const queue = document.getElementById('mq-queue').value.trim();
|
||||||
|
const endpoint = document.getElementById('mq-endpoint').value.trim();
|
||||||
|
const accessKey = document.getElementById('mq-access-key').value.trim();
|
||||||
|
const secretKey = document.getElementById('mq-secret-key').value.trim();
|
||||||
|
const errEl = document.getElementById('mq-create-error');
|
||||||
|
|
||||||
|
if (!name || !fnName || !queue || !accessKey || !secretKey) {
|
||||||
|
errEl.textContent = 'Заполните все поля';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
errEl.textContent = '';
|
||||||
|
try {
|
||||||
|
await apiFetch('/console/api/mqtriggers', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ name, functionName: fnName, queue, sqsEndpoint: endpoint, accessKey, secretKey })
|
||||||
|
});
|
||||||
|
closeMQCreate();
|
||||||
|
await loadMQTriggers();
|
||||||
|
} catch (e) {
|
||||||
|
errEl.textContent = e.message || 'Ошибка создания';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Удаление ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function deleteMQTrigger(name) {
|
||||||
|
if (!confirm(`Удалить MQ-триггер "${name}"?`)) return;
|
||||||
|
try {
|
||||||
|
await apiFetch(`/console/api/mqtriggers/${encodeURIComponent(name)}`, { method: 'DELETE' });
|
||||||
|
await loadMQTriggers();
|
||||||
|
} catch (e) {
|
||||||
|
alert('Ошибка удаления: ' + (e.message || e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Утилита ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
|
}
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: grafana-dashboard-fission
|
||||||
|
namespace: grafana
|
||||||
|
data:
|
||||||
|
fission-overview.json: |
|
||||||
|
{
|
||||||
|
"title": "Fission — Operator Overview",
|
||||||
|
"uid": "fission-overview",
|
||||||
|
"tags": ["fission"],
|
||||||
|
"timezone": "browser",
|
||||||
|
"refresh": "30s",
|
||||||
|
"time": { "from": "now-24h", "to": "now" },
|
||||||
|
"templating": {
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"name": "namespace",
|
||||||
|
"type": "query",
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"query": "SELECT DISTINCT namespace FROM invocations ORDER BY 1",
|
||||||
|
"includeAll": true,
|
||||||
|
"multi": true,
|
||||||
|
"label": "Namespace",
|
||||||
|
"current": { "text": "All", "value": "$__all" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"panels": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"title": "Вызовы в час",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "x": 0, "y": 0, "w": 16, "h": 8 },
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT date_trunc('hour', started_at) AS time, count(*) AS value, namespace FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1, namespace ORDER BY 1",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"title": "Успех vs Ошибки",
|
||||||
|
"type": "piechart",
|
||||||
|
"gridPos": { "x": 16, "y": 0, "w": 8, "h": 8 },
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT CASE WHEN status_code >= 200 AND status_code < 300 THEN 'success' ELSE 'error' END AS metric, count(*) AS value FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"title": "Топ функций по вызовам",
|
||||||
|
"type": "bargauge",
|
||||||
|
"gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 },
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT function_name AS metric, count(*) AS value FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1 ORDER BY 2 DESC LIMIT 10",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"title": "Средняя латентность (ms)",
|
||||||
|
"type": "timeseries",
|
||||||
|
"gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 },
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT date_trunc('hour', started_at) AS time, round(avg(duration_ms)) AS avg_ms, round(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms)) AS p95_ms FROM invocations WHERE started_at BETWEEN $__timeFrom() AND $__timeTo() AND ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) GROUP BY 1 ORDER BY 1",
|
||||||
|
"format": "time_series",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"title": "Последние вызовы",
|
||||||
|
"type": "table",
|
||||||
|
"gridPos": { "x": 0, "y": 16, "w": 24, "h": 8 },
|
||||||
|
"datasource": { "type": "postgres", "uid": "fission-pg" },
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"rawSql": "SELECT started_at AS time, namespace, function_name, trigger_type, event_type, status_code, duration_ms FROM invocations WHERE ('$namespace' = '$__all' OR namespace = ANY(string_to_array('$namespace', ','))) ORDER BY started_at DESC LIMIT 50",
|
||||||
|
"format": "table",
|
||||||
|
"refId": "A"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"schemaVersion": 38
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: grafana-dashboard-providers
|
||||||
|
namespace: grafana
|
||||||
|
data:
|
||||||
|
providers.yaml: |
|
||||||
|
apiVersion: 1
|
||||||
|
providers:
|
||||||
|
- name: fission
|
||||||
|
type: file
|
||||||
|
disableDeletion: false
|
||||||
|
editable: true
|
||||||
|
options:
|
||||||
|
path: /var/lib/grafana/dashboards
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: grafana-datasources
|
||||||
|
namespace: grafana
|
||||||
|
data:
|
||||||
|
postgres.yaml: |
|
||||||
|
apiVersion: 1
|
||||||
|
datasources:
|
||||||
|
- name: PostgreSQL
|
||||||
|
type: postgres
|
||||||
|
uid: fission-pg
|
||||||
|
url: postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432
|
||||||
|
database: sqsdb
|
||||||
|
user: super
|
||||||
|
secureJsonData:
|
||||||
|
password: "BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
|
||||||
|
jsonData:
|
||||||
|
sslmode: disable
|
||||||
|
postgresVersion: 1700
|
||||||
|
timescaledb: false
|
||||||
|
editable: false
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: grafana
|
||||||
|
labels:
|
||||||
|
app: grafana
|
||||||
|
spec:
|
||||||
|
replicas: 1
|
||||||
|
selector:
|
||||||
|
matchLabels:
|
||||||
|
app: grafana
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: grafana
|
||||||
|
spec:
|
||||||
|
securityContext:
|
||||||
|
fsGroup: 472
|
||||||
|
runAsUser: 472
|
||||||
|
containers:
|
||||||
|
- name: grafana
|
||||||
|
image: grafana/grafana:11.6.1
|
||||||
|
imagePullPolicy: IfNotPresent
|
||||||
|
ports:
|
||||||
|
- containerPort: 3000
|
||||||
|
env:
|
||||||
|
- name: GF_SECURITY_ADMIN_USER
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: grafana-admin
|
||||||
|
key: admin-user
|
||||||
|
- name: GF_SECURITY_ADMIN_PASSWORD
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: grafana-admin
|
||||||
|
key: admin-password
|
||||||
|
- name: GF_SERVER_ROOT_URL
|
||||||
|
value: "https://fission.kube5s.ru/grafana"
|
||||||
|
- name: GF_SERVER_DOMAIN
|
||||||
|
value: "fission.kube5s.ru"
|
||||||
|
- name: GF_SERVER_SERVE_FROM_SUB_PATH
|
||||||
|
value: "true"
|
||||||
|
- name: GF_USERS_ALLOW_SIGN_UP
|
||||||
|
value: "false"
|
||||||
|
- name: GF_AUTH_ANONYMOUS_ENABLED
|
||||||
|
value: "false"
|
||||||
|
- name: GF_ORGS_AUTO_ASSIGN_ORG
|
||||||
|
value: "true"
|
||||||
|
- name: GF_ORGS_AUTO_ASSIGN_ORG_ID
|
||||||
|
value: "1"
|
||||||
|
- name: GF_ORGS_AUTO_ASSIGN_ORG_ROLE
|
||||||
|
value: "Viewer"
|
||||||
|
- name: GF_FEATURE_TOGGLES_ENABLE
|
||||||
|
value: "publicDashboards"
|
||||||
|
- name: GF_PATHS_PROVISIONING
|
||||||
|
value: "/etc/grafana/provisioning"
|
||||||
|
volumeMounts:
|
||||||
|
- name: storage
|
||||||
|
mountPath: /var/lib/grafana
|
||||||
|
- name: datasources
|
||||||
|
mountPath: /etc/grafana/provisioning/datasources
|
||||||
|
- name: dashboard-providers
|
||||||
|
mountPath: /etc/grafana/provisioning/dashboards
|
||||||
|
- name: dashboards
|
||||||
|
mountPath: /var/lib/grafana/dashboards
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 50m
|
||||||
|
memory: 128Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 256Mi
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /grafana/api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 30
|
||||||
|
periodSeconds: 30
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /grafana/api/health
|
||||||
|
port: 3000
|
||||||
|
initialDelaySeconds: 10
|
||||||
|
periodSeconds: 10
|
||||||
|
volumes:
|
||||||
|
- name: storage
|
||||||
|
persistentVolumeClaim:
|
||||||
|
claimName: grafana-storage
|
||||||
|
- name: datasources
|
||||||
|
configMap:
|
||||||
|
name: grafana-datasources
|
||||||
|
- name: dashboard-providers
|
||||||
|
configMap:
|
||||||
|
name: grafana-dashboard-providers
|
||||||
|
- name: dashboards
|
||||||
|
configMap:
|
||||||
|
name: grafana-dashboard-fission
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: grafana-subpath
|
||||||
|
namespace: grafana
|
||||||
|
annotations:
|
||||||
|
# Без rewrite — Grafana сама обрабатывает /grafana/... через serve_from_sub_path
|
||||||
|
nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
|
||||||
|
nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
rules:
|
||||||
|
- host: fission.kube5s.ru
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /grafana
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: grafana
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
|
# TLS не нужен — управляется Ingress в namespace fission (fission-tls)
|
||||||
|
# При миграции на grafana.kube5s.ru:
|
||||||
|
# 1. Поменять GF_SERVER_ROOT_URL → https://grafana.kube5s.ru
|
||||||
|
# 2. Убрать GF_SERVER_SERVE_FROM_SUB_PATH (или оставить false)
|
||||||
|
# 3. Создать Ingress в namespace grafana с host grafana.kube5s.ru + TLS
|
||||||
|
# 4. Удалить этот файл
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: PersistentVolumeClaim
|
||||||
|
metadata:
|
||||||
|
name: grafana-storage
|
||||||
|
namespace: grafana
|
||||||
|
spec:
|
||||||
|
accessModes:
|
||||||
|
- ReadWriteOnce
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
storage: 2Gi
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Secret
|
||||||
|
metadata:
|
||||||
|
name: grafana-admin
|
||||||
|
namespace: grafana
|
||||||
|
type: Opaque
|
||||||
|
stringData:
|
||||||
|
admin-user: admin
|
||||||
|
admin-password: "GrafanaAdmin2026!"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: grafana
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: grafana
|
||||||
|
ports:
|
||||||
|
- port: 3000
|
||||||
|
targetPort: 3000
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: grafana
|
||||||
|
namespace: grafana
|
||||||
|
annotations:
|
||||||
|
nginx.ingress.kubernetes.io/force-ssl-redirect: "true"
|
||||||
|
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
||||||
|
spec:
|
||||||
|
ingressClassName: nginx
|
||||||
|
tls:
|
||||||
|
- hosts:
|
||||||
|
- grafana.kube5s.ru
|
||||||
|
secretName: grafana-tls
|
||||||
|
rules:
|
||||||
|
- host: grafana.kube5s.ru
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- path: /
|
||||||
|
pathType: Prefix
|
||||||
|
backend:
|
||||||
|
service:
|
||||||
|
name: grafana
|
||||||
|
port:
|
||||||
|
number: 3000
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import psycopg2
|
||||||
|
|
||||||
|
|
||||||
|
def main(event, context):
|
||||||
|
pg_dsn = os.environ.get(
|
||||||
|
"PG_DSN",
|
||||||
|
"postgresql://super:BQUF5ruECa1ZFlq4wYt3gPJUEmtBMkA9QNK4MM5Sd8al4ArMDlmT16DIKHYBPyif"
|
||||||
|
"@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432"
|
||||||
|
"/sqsdb?sslmode=disable",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Извлекаем тело сообщения из SQS (POST от sqs-consumer)
|
||||||
|
# event — Flask Request object: используем .data (bytes) или .get_json()
|
||||||
|
try:
|
||||||
|
data = event.get_json(force=True, silent=False)
|
||||||
|
except Exception:
|
||||||
|
raw = getattr(event, "data", None) or getattr(event, "body", b"")
|
||||||
|
if isinstance(raw, (bytes, bytearray)):
|
||||||
|
raw = raw.decode("utf-8")
|
||||||
|
data = json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
conn = psycopg2.connect(pg_dsn)
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO weather_metrics
|
||||||
|
(city, country, temperature, feels_like, humidity,
|
||||||
|
pressure, wind_speed, description, recorded_at)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, to_timestamp(%s))
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
data["city"],
|
||||||
|
data["country"],
|
||||||
|
data["temperature"],
|
||||||
|
data["feels_like"],
|
||||||
|
data["humidity"],
|
||||||
|
data["pressure"],
|
||||||
|
data["wind_speed"],
|
||||||
|
data["description"],
|
||||||
|
data["owm_timestamp"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {"status": "ok", "city": data["city"], "temp": data["temperature"]}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
psycopg2-binary==2.9.9
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import boto3
|
||||||
|
from botocore.config import Config
|
||||||
|
|
||||||
|
# Open-Meteo: бесплатный API без ключа, реальные данные.
|
||||||
|
# https://open-meteo.com/en/docs
|
||||||
|
CITIES = [
|
||||||
|
{"name": "Moscow", "country": "RU", "lat": 55.7558, "lon": 37.6173},
|
||||||
|
{"name": "London", "country": "GB", "lat": 51.5074, "lon": -0.1278},
|
||||||
|
{"name": "Paphos", "country": "CY", "lat": 34.7753, "lon": 32.4242},
|
||||||
|
{"name": "Ulyanovsk", "country": "RU", "lat": 54.3282, "lon": 48.3866},
|
||||||
|
{"name": "Santiago", "country": "CL", "lat": -33.4489, "lon": -70.6693},
|
||||||
|
]
|
||||||
|
|
||||||
|
# WMO weather code → описание
|
||||||
|
WMO_DESCRIPTIONS = {
|
||||||
|
0: "clear sky", 1: "mainly clear", 2: "partly cloudy", 3: "overcast",
|
||||||
|
45: "fog", 48: "icy fog", 51: "light drizzle", 53: "drizzle",
|
||||||
|
55: "heavy drizzle", 61: "light rain", 63: "rain", 65: "heavy rain",
|
||||||
|
71: "light snow", 73: "snow", 75: "heavy snow", 80: "rain showers",
|
||||||
|
81: "showers", 82: "violent showers", 95: "thunderstorm",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_city(city):
|
||||||
|
params = {
|
||||||
|
"latitude": city["lat"],
|
||||||
|
"longitude": city["lon"],
|
||||||
|
"current": "temperature_2m,apparent_temperature,relative_humidity_2m,surface_pressure,wind_speed_10m,weather_code",
|
||||||
|
"wind_speed_unit": "ms",
|
||||||
|
"timezone": "UTC",
|
||||||
|
}
|
||||||
|
resp = requests.get(
|
||||||
|
"https://api.open-meteo.com/v1/forecast",
|
||||||
|
params=params,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
cur = resp.json()["current"]
|
||||||
|
code = cur.get("weather_code", 0)
|
||||||
|
return {
|
||||||
|
"city": city["name"],
|
||||||
|
"country": city["country"],
|
||||||
|
"temperature": round(cur["temperature_2m"], 1),
|
||||||
|
"feels_like": round(cur["apparent_temperature"], 1),
|
||||||
|
"humidity": int(cur["relative_humidity_2m"]),
|
||||||
|
"pressure": int(cur["surface_pressure"]),
|
||||||
|
"wind_speed": round(cur["wind_speed_10m"], 1),
|
||||||
|
"description": WMO_DESCRIPTIONS.get(code, f"wmo:{code}"),
|
||||||
|
"owm_timestamp": int(time.time()),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main(event, context):
|
||||||
|
sqs_endpoint = os.environ.get(
|
||||||
|
"SQS_ENDPOINT", "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
|
||||||
|
)
|
||||||
|
access_key = os.environ.get("SQS_ACCESS_KEY", "SSAK-a9964f2723bc6d347f48d153")
|
||||||
|
secret_key = os.environ.get(
|
||||||
|
"SQS_SECRET_KEY",
|
||||||
|
"2069e1ce05aaf94efe07aee18697352879e7626df239a9c71af0e9650b43bdd6",
|
||||||
|
)
|
||||||
|
queue_name = os.environ.get("SQS_QUEUE_NAME", "weather-data")
|
||||||
|
|
||||||
|
cfg = Config(signature_version="s3v4", s3={"addressing_style": "path"})
|
||||||
|
sqs = boto3.client(
|
||||||
|
"sqs",
|
||||||
|
endpoint_url=sqs_endpoint,
|
||||||
|
aws_access_key_id=access_key,
|
||||||
|
aws_secret_access_key=secret_key,
|
||||||
|
region_name="us-east-1",
|
||||||
|
config=cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
queue_url = sqs.get_queue_url(QueueName=queue_name)["QueueUrl"]
|
||||||
|
except Exception:
|
||||||
|
queue_url = sqs.create_queue(QueueName=queue_name)["QueueUrl"]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for city in CITIES:
|
||||||
|
try:
|
||||||
|
msg = fetch_city(city)
|
||||||
|
sqs.send_message(QueueUrl=queue_url, MessageBody=json.dumps(msg))
|
||||||
|
results.append({"city": msg["city"], "temp": msg["temperature"]})
|
||||||
|
except Exception as e:
|
||||||
|
results.append({"city": city["name"], "error": str(e)})
|
||||||
|
|
||||||
|
return {"status": "ok", "sent": len(results), "results": results}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
requests==2.31.0
|
||||||
|
boto3==1.34.0
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
terraform {
|
||||||
|
required_providers {
|
||||||
|
fission = {
|
||||||
|
source = "nail/fission"
|
||||||
|
version = "~> 0.2.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provider "fission" {
|
||||||
|
kubeconfig_path = var.kubeconfig_path
|
||||||
|
namespace = var.namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Переменные ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
variable "kubeconfig_path" {
|
||||||
|
default = "/home/naeel/.kube/config"
|
||||||
|
description = "Путь к kubeconfig."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "namespace" {
|
||||||
|
default = "fission-weather"
|
||||||
|
description = "Namespace для функций и триггеров."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "sqs_access_key" {
|
||||||
|
sensitive = true
|
||||||
|
description = "SQS Access Key."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "sqs_secret_key" {
|
||||||
|
sensitive = true
|
||||||
|
description = "SQS Secret Key."
|
||||||
|
}
|
||||||
|
|
||||||
|
variable "pg_dsn" {
|
||||||
|
sensitive = true
|
||||||
|
description = "PostgreSQL DSN для записи метрик. Пример: postgresql://user:pass@host:5432/db?sslmode=disable"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── IoT-устройство — виртуальная метеостанция ────────────────────────
|
||||||
|
|
||||||
|
resource "fission_iot_device" "weather_station" {
|
||||||
|
name = "weather-station"
|
||||||
|
device_id = "weather-station-01"
|
||||||
|
namespace = "sless"
|
||||||
|
metadata = {
|
||||||
|
type = "weather-station"
|
||||||
|
location = "multi-city"
|
||||||
|
cities = "Moscow,London,Paphos,Ulyanovsk,Santiago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Python environment ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
resource "fission_environment" "python" {
|
||||||
|
name = "weather-python"
|
||||||
|
image = "naeel/fission-python-env:v1.1"
|
||||||
|
version = 2
|
||||||
|
namespace = var.namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Пакет: fetcher ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
resource "fission_package" "fetcher" {
|
||||||
|
name = "weather-fetcher-pkg"
|
||||||
|
environment = fission_environment.python.name
|
||||||
|
namespace = var.namespace
|
||||||
|
source_dir = "${path.module}/fetcher"
|
||||||
|
deploy_type = "literal"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Пакет: consumer ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
resource "fission_package" "consumer" {
|
||||||
|
name = "weather-consumer-pkg"
|
||||||
|
environment = fission_environment.python.name
|
||||||
|
namespace = var.namespace
|
||||||
|
source_dir = "${path.module}/consumer"
|
||||||
|
deploy_type = "literal"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Функция: fetcher (читает Open-Meteo, пишет в SQS) ───────────────
|
||||||
|
# Open-Meteo: бесплатный API без ключа. https://open-meteo.com
|
||||||
|
resource "fission_function" "fetcher" {
|
||||||
|
name = "weather-fetcher"
|
||||||
|
environment = fission_environment.python.name
|
||||||
|
namespace = var.namespace
|
||||||
|
package_name = fission_package.fetcher.name
|
||||||
|
entrypoint = "main"
|
||||||
|
# Env vars задаются через K8s Secret weather-fetcher-env (namespace: var.namespace)
|
||||||
|
# Ключи: SQS_ACCESS_KEY, SQS_SECRET_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── CRON trigger: каждые 10 минут ────────────────────────────────────
|
||||||
|
|
||||||
|
resource "fission_cron_trigger" "fetcher" {
|
||||||
|
name = "weather-cron"
|
||||||
|
function = fission_function.fetcher.name
|
||||||
|
namespace = var.namespace
|
||||||
|
cron = "*/10 * * * *"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Функция: consumer (читает из SQS, пишет в PG) ────────────────────
|
||||||
|
|
||||||
|
resource "fission_function" "consumer" {
|
||||||
|
name = "weather-consumer"
|
||||||
|
environment = fission_environment.python.name
|
||||||
|
namespace = var.namespace
|
||||||
|
package_name = fission_package.consumer.name
|
||||||
|
entrypoint = "main"
|
||||||
|
# Env vars: PG_DSN задаётся через K8s Secret weather-consumer-env
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── MQ trigger: SQS queue → consumer function ────────────────────────
|
||||||
|
|
||||||
|
resource "fission_mq_trigger" "weather" {
|
||||||
|
name = "weather-mq"
|
||||||
|
function = fission_function.consumer.name
|
||||||
|
namespace = var.namespace
|
||||||
|
queue = "weather-data"
|
||||||
|
access_key = var.sqs_access_key
|
||||||
|
secret_key = var.sqs_secret_key
|
||||||
|
sqs_endpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Outputs ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
output "iot_device_phase" {
|
||||||
|
value = fission_iot_device.weather_station.phase
|
||||||
|
description = "Статус IoT-устройства weather-station."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "iot_mqtt_username" {
|
||||||
|
value = fission_iot_device.weather_station.mqtt_username
|
||||||
|
description = "MQTT username для weather-station."
|
||||||
|
}
|
||||||
|
|
||||||
|
output "iot_topic_prefix" {
|
||||||
|
value = fission_iot_device.weather_station.topic_prefix
|
||||||
|
description = "MQTT topic prefix для weather-station."
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
-- Миграция: таблица метрик погоды для weather-demo
|
||||||
|
-- Применять: psql $PG_DSN -f migration.sql
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS weather_metrics (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
city TEXT NOT NULL,
|
||||||
|
country TEXT NOT NULL,
|
||||||
|
temperature NUMERIC(5,2),
|
||||||
|
feels_like NUMERIC(5,2),
|
||||||
|
humidity INTEGER,
|
||||||
|
pressure INTEGER,
|
||||||
|
wind_speed NUMERIC(6,2),
|
||||||
|
description TEXT,
|
||||||
|
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_weather_city_time
|
||||||
|
ON weather_metrics (city, recorded_at DESC);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Скопируй в terraform.tfvars и заполни значения.
|
||||||
|
# НЕ коммитить файл с реальными секретами!
|
||||||
|
|
||||||
|
kubeconfig_path = "/home/naeel/.kube/config"
|
||||||
|
namespace = "fission-weather"
|
||||||
|
|
||||||
|
owm_api_key = "YOUR_OPENWEATHERMAP_API_KEY"
|
||||||
|
sqs_access_key = "SSAK-a9964f2723bc6d347f48d153"
|
||||||
|
sqs_secret_key = "YOUR_SQS_SECRET_KEY"
|
||||||
|
pg_dsn = "postgresql://super:PASSWORD@postgresqlk8s-master.dc5db45d-f8b4-4fd0-ad33-ec4dd017f2d5.svc.cluster.local:5432/sqsdb?sslmode=disable"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# Демо-пайплайн: IoT → SQS → Fission
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
1. **Крон-функция (Fission)**
|
||||||
|
- Парсит данные о погоде с сайта для нескольких городов
|
||||||
|
- Эмулирует IoT-датчики: отправляет данные в IoT-сервис (MQTT/HTTP)
|
||||||
|
|
||||||
|
2. **IoT-сервис**
|
||||||
|
- Принимает данные от "датчиков"
|
||||||
|
- Публикует сообщения в очередь (SQS или аналог)
|
||||||
|
|
||||||
|
3. **Очередь (SQS)**
|
||||||
|
- Хранит сообщения от IoT
|
||||||
|
- Триггерит Fission-функцию при появлении новых данных (MessageQueue Trigger)
|
||||||
|
|
||||||
|
4. **Fission-функция**
|
||||||
|
- Получает данные из очереди
|
||||||
|
- Записывает их в таблицу (PostgreSQL, ClickHouse и т.д.)
|
||||||
|
|
||||||
|
## Требования к Fission
|
||||||
|
|
||||||
|
- Необходим MQ-триггер (MessageQueue Trigger) для автоматического запуска функции по сообщениям из очереди.
|
||||||
|
- Сейчас в Fission есть HTTP, Cron, Event, но нет универсального MQ-триггера.
|
||||||
|
- Возможные варианты:
|
||||||
|
- Реализовать внешний watcher (SQS/Kafka/RabbitMQ → invoke HTTP endpoint Fission)
|
||||||
|
- Добавить поддержку MQ-триггера в сам Fission (новый CRD + контроллер)
|
||||||
|
|
||||||
|
## Примечания
|
||||||
|
|
||||||
|
- Все компоненты связаны через API/очереди, каждый слой изолирован.
|
||||||
|
- Такой пайплайн типовой для облачных платформ и легко масштабируется.
|
||||||
|
- Для MVP достаточно watcher-а очереди, который вызывает функцию через HTTP.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Если потребуется — расписать детальный план интеграции или примеры кода для каждого этапа.
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
FROM ghcr.io/fission/python-env:latest
|
FROM ghcr.io/fission/python-env:latest
|
||||||
|
RUN pip install --no-cache-dir boto3==1.34.0 requests==2.31.0 psycopg2-binary==2.9.9
|
||||||
COPY server.py /app/server.py
|
COPY server.py /app/server.py
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Dockerfile для sqs-consumer
|
||||||
|
# Назначение: поллит SQS-совместимую очередь (shared-sqs) и вызывает Fission-функцию.
|
||||||
|
# Образ: naeel/sqs-consumer:v1.0
|
||||||
|
# 2026-05-11
|
||||||
|
|
||||||
|
FROM golang:1.22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY go.mod ./
|
||||||
|
COPY main.go ./
|
||||||
|
RUN go mod tidy && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o sqs-consumer .
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static:nonroot
|
||||||
|
WORKDIR /
|
||||||
|
COPY --from=builder /app/sqs-consumer .
|
||||||
|
USER 65532:65532
|
||||||
|
ENTRYPOINT ["/sqs-consumer"]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module sqs-consumer
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.26.1
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.27.11
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.11
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.27.11 h1:f47rANd2LQEYHda2ddSCKYId18/8BhSRM4BULGmfgNA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.27.11/go.mod h1:SMsV78RIOYdve1vf36z8LmnszlRWkwMQtomCAI0/mIE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.11 h1:YuIB1dJNf1Re822rriUOTxopaHHvIq0l/pX3fwO+Tzs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.11/go.mod h1:AQtFPsDH9bI2O+71anW6EKL+NcD7LG3dpKGMV4SShgo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4 h1:mE2ysZMEeQ3ulHWs4mmc4fZEhOfeY1o6QXAfDqjbSgw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sqs v1.31.4/go.mod h1:lCN2yKnj+Sp9F6UzpoPPTir+tSaC9Jwf6LcmTqnXFZw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5 h1:vN8hEbpRnL7+Hopy9dzmRle1xmDc7o8tmY0klsr175w=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.20.5/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4 h1:Jux+gDDyi1Lruk+KHF91tK2KCuY61kzoCpvtvJJBtOE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.23.4/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6 h1:cwIxeBttqPN3qkaAjcEcsh8NYr8n2HZPkcKgPAi1phU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.28.6/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw=
|
||||||
|
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
|
||||||
|
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
// sqs-consumer — SQS poller → Fission function invoker
|
||||||
|
//
|
||||||
|
// Env vars:
|
||||||
|
// SQS_ENDPOINT — URL SQS сервиса
|
||||||
|
// SQS_QUEUE_NAME — имя очереди (обязательно)
|
||||||
|
// SQS_ACCESS_KEY — AccessKeyId тенанта
|
||||||
|
// SQS_SECRET_KEY — SecretAccessKey тенанта
|
||||||
|
// SQS_REGION — регион (default: us-east-1)
|
||||||
|
// FUNCTION_URL — полный URL функции
|
||||||
|
// ROUTER_USERNAME — логин для /auth/login роутера (optional)
|
||||||
|
// ROUTER_PASSWORD — пароль для /auth/login роутера (optional)
|
||||||
|
// ROUTER_LOGIN_URL — URL /auth/login (default: выводится из FUNCTION_URL)
|
||||||
|
// AUTH_TOKEN — статичный Bearer токен (если ROUTER_USERNAME не задан)
|
||||||
|
// POLL_INTERVAL — интервал поллинга в секундах (default: 5)
|
||||||
|
// MAX_MESSAGES — макс. сообщений за раз (default: 1)
|
||||||
|
// MAX_RETRIES — попыток вызова функции перед skip (default: 3)
|
||||||
|
//
|
||||||
|
// 2026-05-12
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/aws/aws-sdk-go-v2/aws"
|
||||||
|
awsconfig "github.com/aws/aws-sdk-go-v2/config"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||||
|
"github.com/aws/aws-sdk-go-v2/service/sqs"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tokenManager управляет JWT токеном с автообновлением.
|
||||||
|
type tokenManager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
client *http.Client
|
||||||
|
loginURL string
|
||||||
|
username string
|
||||||
|
password string
|
||||||
|
staticToken string
|
||||||
|
cached string
|
||||||
|
expiresAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTokenManager(client *http.Client, functionURL, loginURL, username, password, staticToken string) *tokenManager {
|
||||||
|
if loginURL == "" && username != "" {
|
||||||
|
if i := strings.Index(functionURL, "://"); i >= 0 {
|
||||||
|
rest := functionURL[i+3:]
|
||||||
|
if j := strings.Index(rest, "/"); j >= 0 {
|
||||||
|
loginURL = functionURL[:i+3] + rest[:j] + "/auth/login"
|
||||||
|
} else {
|
||||||
|
loginURL = functionURL + "/auth/login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &tokenManager{
|
||||||
|
client: client,
|
||||||
|
loginURL: loginURL,
|
||||||
|
username: username,
|
||||||
|
password: password,
|
||||||
|
staticToken: staticToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tm *tokenManager) getToken(log *slog.Logger) string {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
|
||||||
|
if tm.username == "" {
|
||||||
|
return tm.staticToken
|
||||||
|
}
|
||||||
|
if tm.cached != "" && time.Now().Add(30*time.Second).Before(tm.expiresAt) {
|
||||||
|
return tm.cached
|
||||||
|
}
|
||||||
|
token, exp := tm.login(log)
|
||||||
|
if token != "" {
|
||||||
|
tm.cached = token
|
||||||
|
tm.expiresAt = exp
|
||||||
|
log.Info("router token refreshed", "expiresAt", exp)
|
||||||
|
}
|
||||||
|
return tm.cached
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tm *tokenManager) login(log *slog.Logger) (string, time.Time) {
|
||||||
|
body, _ := json.Marshal(map[string]string{"username": tm.username, "password": tm.password})
|
||||||
|
resp, err := tm.client.Post(tm.loginURL, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
log.Error("router login failed", "err", err)
|
||||||
|
return "", time.Time{}
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
log.Error("router login non-2xx", "status", resp.StatusCode)
|
||||||
|
return "", time.Time{}
|
||||||
|
}
|
||||||
|
var result struct {
|
||||||
|
AccessToken string `json:"accesstoken"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.AccessToken == "" {
|
||||||
|
log.Error("router login decode failed", "err", err)
|
||||||
|
return "", time.Time{}
|
||||||
|
}
|
||||||
|
return result.AccessToken, time.Now().Add(5 * time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tm *tokenManager) invalidate() {
|
||||||
|
tm.mu.Lock()
|
||||||
|
defer tm.mu.Unlock()
|
||||||
|
tm.cached = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
log := slog.New(slog.NewJSONHandler(os.Stdout, nil))
|
||||||
|
|
||||||
|
endpoint := getenv("SQS_ENDPOINT", "http://shared-sqs.shared-sqs.svc.cluster.local:4100")
|
||||||
|
queueName := mustenv("SQS_QUEUE_NAME")
|
||||||
|
accessKey := mustenv("SQS_ACCESS_KEY")
|
||||||
|
secretKey := mustenv("SQS_SECRET_KEY")
|
||||||
|
region := getenv("SQS_REGION", "us-east-1")
|
||||||
|
functionURL := mustenv("FUNCTION_URL")
|
||||||
|
routerUser := getenv("ROUTER_USERNAME", "")
|
||||||
|
routerPass := getenv("ROUTER_PASSWORD", "")
|
||||||
|
routerLoginURL := getenv("ROUTER_LOGIN_URL", "")
|
||||||
|
staticToken := getenv("AUTH_TOKEN", "")
|
||||||
|
pollSec := parseInt(getenv("POLL_INTERVAL", "5"), 5)
|
||||||
|
maxMsg := int32(parseInt(getenv("MAX_MESSAGES", "1"), 1))
|
||||||
|
maxRetries := parseInt(getenv("MAX_RETRIES", "3"), 3)
|
||||||
|
|
||||||
|
customResolver := aws.EndpointResolverWithOptionsFunc(
|
||||||
|
func(service, reg string, opts ...interface{}) (aws.Endpoint, error) {
|
||||||
|
return aws.Endpoint{URL: endpoint, HostnameImmutable: true}, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
cfg, err := awsconfig.LoadDefaultConfig(context.Background(),
|
||||||
|
awsconfig.WithRegion(region),
|
||||||
|
awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKey, secretKey, "")),
|
||||||
|
awsconfig.WithEndpointResolverWithOptions(customResolver),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Error("failed to create AWS config", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := sqs.NewFromConfig(cfg)
|
||||||
|
ctx := context.Background()
|
||||||
|
urlResult, err := client.GetQueueUrl(ctx, &sqs.GetQueueUrlInput{QueueName: aws.String(queueName)})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("GetQueueUrl failed", "queue", queueName, "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
queueURL := aws.ToString(urlResult.QueueUrl)
|
||||||
|
log.Info("sqs-consumer started", "queue", queueName, "endpoint", endpoint, "functionURL", functionURL)
|
||||||
|
|
||||||
|
httpClient := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
tm := newTokenManager(httpClient, functionURL, routerLoginURL, routerUser, routerPass, staticToken)
|
||||||
|
|
||||||
|
ticker := time.NewTicker(time.Duration(pollSec) * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for range ticker.C {
|
||||||
|
poll(ctx, log, client, httpClient, tm, queueURL, functionURL, maxMsg, maxRetries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func poll(ctx context.Context, log *slog.Logger, sqsClient *sqs.Client, httpClient *http.Client,
|
||||||
|
tm *tokenManager, queueURL, functionURL string, maxMsg int32, maxRetries int) {
|
||||||
|
|
||||||
|
result, err := sqsClient.ReceiveMessage(ctx, &sqs.ReceiveMessageInput{
|
||||||
|
QueueUrl: aws.String(queueURL),
|
||||||
|
MaxNumberOfMessages: maxMsg,
|
||||||
|
WaitTimeSeconds: 5,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Error("SQS ReceiveMessage failed", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, msg := range result.Messages {
|
||||||
|
body := aws.ToString(msg.Body)
|
||||||
|
receipt := aws.ToString(msg.ReceiptHandle)
|
||||||
|
if invokeWithRetry(log, httpClient, tm, functionURL, body, maxRetries) {
|
||||||
|
if _, err := sqsClient.DeleteMessage(ctx, &sqs.DeleteMessageInput{
|
||||||
|
QueueUrl: aws.String(queueURL),
|
||||||
|
ReceiptHandle: aws.String(receipt),
|
||||||
|
}); err != nil {
|
||||||
|
log.Error("DeleteMessage failed", "err", err)
|
||||||
|
} else {
|
||||||
|
log.Info("message processed", "msgId", aws.ToString(msg.MessageId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func invokeWithRetry(log *slog.Logger, client *http.Client, tm *tokenManager, url, body string, maxRetries int) bool {
|
||||||
|
for attempt := 1; attempt <= maxRetries; attempt++ {
|
||||||
|
token := tm.getToken(log)
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBufferString(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("function invoke error", "attempt", attempt, "err", err)
|
||||||
|
time.Sleep(time.Duration(attempt) * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
log.Warn("got 401, refreshing token", "attempt", attempt)
|
||||||
|
tm.invalidate()
|
||||||
|
} else {
|
||||||
|
log.Warn("function returned non-2xx", "attempt", attempt, "status", resp.StatusCode)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Duration(attempt) * time.Second)
|
||||||
|
}
|
||||||
|
log.Error("all retries exhausted, skipping message", "url", url)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenv(key, def string) string {
|
||||||
|
if v := os.Getenv(key); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustenv(key string) string {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: env var %s is required\n", key)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(s string, def int) int {
|
||||||
|
n, err := strconv.Atoi(s)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -4,7 +4,10 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
k8sresource "k8s.io/apimachinery/pkg/api/resource"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
"k8s.io/apimachinery/pkg/runtime/schema"
|
"k8s.io/apimachinery/pkg/runtime/schema"
|
||||||
@@ -38,6 +41,28 @@ var httpTriggerGVR = schema.GroupVersionResource{
|
|||||||
Resource: "httptriggers",
|
Resource: "httptriggers",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var timeTriggerGVR = schema.GroupVersionResource{
|
||||||
|
Group: "fission.io",
|
||||||
|
Version: "v1",
|
||||||
|
Resource: "timetriggers",
|
||||||
|
}
|
||||||
|
|
||||||
|
var iotDeviceGVR = schema.GroupVersionResource{
|
||||||
|
Group: "iot.kube5s.ru",
|
||||||
|
Version: "v1alpha1",
|
||||||
|
Resource: "iotdevices",
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
sqsConsumerImage = "naeel/sqs-consumer:v1.0"
|
||||||
|
sqsDefaultEndpoint = "http://shared-sqs.shared-sqs.svc.cluster.local:4100"
|
||||||
|
fissionRouterBase = "http://router.fission.svc.cluster.local"
|
||||||
|
mqManagedByLabel = "app.kubernetes.io/managed-by"
|
||||||
|
mqManagedByVal = "fission-terraform"
|
||||||
|
mqComponentLabel = "component"
|
||||||
|
mqComponentVal = "mq-trigger"
|
||||||
|
)
|
||||||
|
|
||||||
// Client хранит клиентов Kubernetes API для работы с CRD Fission.
|
// Client хранит клиентов Kubernetes API для работы с CRD Fission.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
DynClient dynamic.Interface
|
DynClient dynamic.Interface
|
||||||
@@ -253,3 +278,242 @@ func (c *Client) DeleteHTTPTrigger(ctx context.Context, namespace, name string)
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── MQ Trigger (K8s Deployment + Secret) ────────────────────────────
|
||||||
|
|
||||||
|
func mqSecretName(name string) string { return "sqs-mq-" + name }
|
||||||
|
func mqDeployName(name string) string { return "mq-" + name }
|
||||||
|
|
||||||
|
// MQTriggerInfo — данные MQ trigger, считанные из K8s.
|
||||||
|
type MQTriggerInfo struct {
|
||||||
|
Function string
|
||||||
|
Queue string
|
||||||
|
SQSEndpoint string
|
||||||
|
DeployUID string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateMQTrigger создаёт Secret + Deployment для SQS consumer.
|
||||||
|
func (c *Client) CreateMQTrigger(ctx context.Context, name, ns, functionName, queue, sqsEndpoint, accessKey, secretKey string) (*MQTriggerInfo, error) {
|
||||||
|
if sqsEndpoint == "" {
|
||||||
|
sqsEndpoint = sqsDefaultEndpoint
|
||||||
|
}
|
||||||
|
functionURL := fissionRouterBase + "/" + functionName
|
||||||
|
secretName := mqSecretName(name)
|
||||||
|
deployName := mqDeployName(name)
|
||||||
|
|
||||||
|
labels := map[string]string{
|
||||||
|
mqManagedByLabel: mqManagedByVal,
|
||||||
|
mqComponentLabel: mqComponentVal,
|
||||||
|
"mq-trigger-name": name,
|
||||||
|
}
|
||||||
|
|
||||||
|
secret := &corev1.Secret{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: secretName, Namespace: ns, Labels: labels},
|
||||||
|
StringData: map[string]string{
|
||||||
|
"SQS_ACCESS_KEY": accessKey,
|
||||||
|
"SQS_SECRET_KEY": secretKey,
|
||||||
|
"SQS_ENDPOINT": sqsEndpoint,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if _, err := c.K8sClient.CoreV1().Secrets(ns).Create(ctx, secret, metav1.CreateOptions{}); err != nil {
|
||||||
|
return nil, fmt.Errorf("create mq secret %q: %w", secretName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
replicas := int32(1)
|
||||||
|
deploy := &appsv1.Deployment{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
Name: deployName,
|
||||||
|
Namespace: ns,
|
||||||
|
Labels: labels,
|
||||||
|
Annotations: map[string]string{
|
||||||
|
"fission-terraform/mq-trigger-name": name,
|
||||||
|
"fission-terraform/function": functionName,
|
||||||
|
"fission-terraform/queue": queue,
|
||||||
|
"fission-terraform/sqs-endpoint": sqsEndpoint,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Spec: appsv1.DeploymentSpec{
|
||||||
|
Replicas: &replicas,
|
||||||
|
Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mq-trigger-name": name}},
|
||||||
|
Template: corev1.PodTemplateSpec{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{
|
||||||
|
"mq-trigger-name": name,
|
||||||
|
mqComponentLabel: mqComponentVal,
|
||||||
|
}},
|
||||||
|
Spec: corev1.PodSpec{
|
||||||
|
Containers: []corev1.Container{{
|
||||||
|
Name: "sqs-consumer",
|
||||||
|
Image: sqsConsumerImage,
|
||||||
|
ImagePullPolicy: corev1.PullAlways,
|
||||||
|
Env: []corev1.EnvVar{
|
||||||
|
{Name: "SQS_QUEUE_NAME", Value: queue},
|
||||||
|
{Name: "SQS_REGION", Value: "us-east-1"},
|
||||||
|
{Name: "FUNCTION_URL", Value: functionURL},
|
||||||
|
{Name: "POLL_INTERVAL", Value: "5"},
|
||||||
|
{Name: "MAX_MESSAGES", Value: "1"},
|
||||||
|
{Name: "MAX_RETRIES", Value: "3"},
|
||||||
|
},
|
||||||
|
EnvFrom: []corev1.EnvFromSource{{
|
||||||
|
SecretRef: &corev1.SecretEnvSource{
|
||||||
|
LocalObjectReference: corev1.LocalObjectReference{Name: secretName},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
Resources: corev1.ResourceRequirements{
|
||||||
|
Limits: corev1.ResourceList{
|
||||||
|
corev1.ResourceCPU: k8sresource.MustParse("50m"),
|
||||||
|
corev1.ResourceMemory: k8sresource.MustParse("32Mi"),
|
||||||
|
},
|
||||||
|
Requests: corev1.ResourceList{
|
||||||
|
corev1.ResourceCPU: k8sresource.MustParse("10m"),
|
||||||
|
corev1.ResourceMemory: k8sresource.MustParse("16Mi"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
created, err := c.K8sClient.AppsV1().Deployments(ns).Create(ctx, deploy, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
_ = c.K8sClient.CoreV1().Secrets(ns).Delete(ctx, secretName, metav1.DeleteOptions{})
|
||||||
|
return nil, fmt.Errorf("create mq deployment %q: %w", deployName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &MQTriggerInfo{
|
||||||
|
Function: functionName,
|
||||||
|
Queue: queue,
|
||||||
|
SQSEndpoint: sqsEndpoint,
|
||||||
|
DeployUID: string(created.UID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMQTrigger возвращает информацию о MQ trigger по аннотациям Deployment.
|
||||||
|
func (c *Client) GetMQTrigger(ctx context.Context, ns, name string) (*MQTriggerInfo, error) {
|
||||||
|
deploy, err := c.K8sClient.AppsV1().Deployments(ns).Get(ctx, mqDeployName(name), metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get mq deployment %q: %w", name, err)
|
||||||
|
}
|
||||||
|
ann := deploy.Annotations
|
||||||
|
return &MQTriggerInfo{
|
||||||
|
Function: ann["fission-terraform/function"],
|
||||||
|
Queue: ann["fission-terraform/queue"],
|
||||||
|
SQSEndpoint: ann["fission-terraform/sqs-endpoint"],
|
||||||
|
DeployUID: string(deploy.UID),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMQTrigger удаляет Deployment + Secret MQ trigger.
|
||||||
|
func (c *Client) DeleteMQTrigger(ctx context.Context, ns, name string) error {
|
||||||
|
dErr := c.K8sClient.AppsV1().Deployments(ns).Delete(ctx, mqDeployName(name), metav1.DeleteOptions{})
|
||||||
|
sErr := c.K8sClient.CoreV1().Secrets(ns).Delete(ctx, mqSecretName(name), metav1.DeleteOptions{})
|
||||||
|
if dErr != nil && !apierrors.IsNotFound(dErr) {
|
||||||
|
return fmt.Errorf("delete mq deployment %q: %w", name, dErr)
|
||||||
|
}
|
||||||
|
if sErr != nil && !apierrors.IsNotFound(sErr) {
|
||||||
|
return fmt.Errorf("delete mq secret %q: %w", name, sErr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TimeTrigger (Fission CRD) ────────────────────────────────────────
|
||||||
|
|
||||||
|
// CreateTimeTrigger создаёт объект TimeTrigger (CRON) в заданном namespace.
|
||||||
|
func (c *Client) CreateTimeTrigger(ctx context.Context, tt *unstructured.Unstructured) (*unstructured.Unstructured, error) {
|
||||||
|
created, err := c.DynClient.Resource(timeTriggerGVR).Namespace(tt.GetNamespace()).Create(ctx, tt, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create time trigger %q: %w", tt.GetName(), err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTimeTrigger возвращает объект TimeTrigger по имени.
|
||||||
|
func (c *Client) GetTimeTrigger(ctx context.Context, namespace, name string) (*unstructured.Unstructured, error) {
|
||||||
|
tt, err := c.DynClient.Resource(timeTriggerGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get time trigger %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return tt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTimeTrigger обновляет объект TimeTrigger.
|
||||||
|
func (c *Client) UpdateTimeTrigger(ctx context.Context, tt *unstructured.Unstructured) (*unstructured.Unstructured, error) {
|
||||||
|
updated, err := c.DynClient.Resource(timeTriggerGVR).Namespace(tt.GetNamespace()).Update(ctx, tt, metav1.UpdateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("update time trigger %q: %w", tt.GetName(), err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTimeTrigger удаляет объект TimeTrigger по имени.
|
||||||
|
func (c *Client) DeleteTimeTrigger(ctx context.Context, namespace, name string) error {
|
||||||
|
if err := c.DynClient.Resource(timeTriggerGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("delete time trigger %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── IoTDevice (CRD iot.kube5s.ru/v1alpha1) ──────────────────────────
|
||||||
|
|
||||||
|
// IoTDeviceInfo — данные IoTDevice из статуса CRD.
|
||||||
|
type IoTDeviceInfo struct {
|
||||||
|
Phase string
|
||||||
|
MQTTUsername string
|
||||||
|
SecretName string
|
||||||
|
TopicPrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateIoTDevice создаёт объект IoTDevice.
|
||||||
|
func (c *Client) CreateIoTDevice(ctx context.Context, name, namespace, deviceID string, metadata map[string]string) (*IoTDeviceInfo, error) {
|
||||||
|
obj := &unstructured.Unstructured{
|
||||||
|
Object: map[string]any{
|
||||||
|
"apiVersion": "iot.kube5s.ru/v1alpha1",
|
||||||
|
"kind": "IoTDevice",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"namespace": namespace,
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"deviceId": deviceID,
|
||||||
|
"enabled": true,
|
||||||
|
"metadata": metadata,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
created, err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Create(ctx, obj, metav1.CreateOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create iot device %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return extractIoTDeviceInfo(created), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIoTDevice возвращает IoTDevice по имени.
|
||||||
|
func (c *Client) GetIoTDevice(ctx context.Context, namespace, name string) (*IoTDeviceInfo, error) {
|
||||||
|
obj, err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Get(ctx, name, metav1.GetOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get iot device %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return extractIoTDeviceInfo(obj), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteIoTDevice удаляет IoTDevice по имени.
|
||||||
|
func (c *Client) DeleteIoTDevice(ctx context.Context, namespace, name string) error {
|
||||||
|
if err := c.DynClient.Resource(iotDeviceGVR).Namespace(namespace).Delete(ctx, name, metav1.DeleteOptions{}); err != nil {
|
||||||
|
return fmt.Errorf("delete iot device %q: %w", name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractIoTDeviceInfo(obj *unstructured.Unstructured) *IoTDeviceInfo {
|
||||||
|
status, _, _ := unstructured.NestedMap(obj.Object, "status")
|
||||||
|
phase, _ := status["phase"].(string)
|
||||||
|
mqttUsername, _ := status["mqttUsername"].(string)
|
||||||
|
secretName, _ := status["secretName"].(string)
|
||||||
|
topicPrefix, _ := status["topicPrefix"].(string)
|
||||||
|
return &IoTDeviceInfo{
|
||||||
|
Phase: phase,
|
||||||
|
MQTTUsername: mqttUsername,
|
||||||
|
SecretName: secretName,
|
||||||
|
TopicPrefix: topicPrefix,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,6 +99,9 @@ func (p *FissionProvider) Resources(_ context.Context) []func() resource.Resourc
|
|||||||
resources.NewFunctionResource,
|
resources.NewFunctionResource,
|
||||||
resources.NewHTTPTriggerResource,
|
resources.NewHTTPTriggerResource,
|
||||||
resources.NewSimpleFunctionResource,
|
resources.NewSimpleFunctionResource,
|
||||||
|
resources.NewMQTriggerResource,
|
||||||
|
resources.NewCronTriggerResource,
|
||||||
|
resources.NewIoTDeviceResource,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
|
||||||
|
|
||||||
|
"terraform-provider-fission/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ resource.Resource = &CronTriggerResource{}
|
||||||
|
|
||||||
|
// CronTriggerResource управляет Fission TimeTrigger (CRON).
|
||||||
|
type CronTriggerResource struct {
|
||||||
|
client *client.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type cronTriggerResourceModel struct {
|
||||||
|
ID types.String `tfsdk:"id"`
|
||||||
|
Name types.String `tfsdk:"name"`
|
||||||
|
Namespace types.String `tfsdk:"namespace"`
|
||||||
|
Function types.String `tfsdk:"function"`
|
||||||
|
Cron types.String `tfsdk:"cron"`
|
||||||
|
UID types.String `tfsdk:"uid"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCronTriggerResource() resource.Resource {
|
||||||
|
return &CronTriggerResource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||||
|
resp.TypeName = req.ProviderTypeName + "_cron_trigger"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
|
resp.Schema = schema.Schema{
|
||||||
|
Description: "Fission TimeTrigger — запускает функцию по CRON-расписанию.",
|
||||||
|
Attributes: map[string]schema.Attribute{
|
||||||
|
"id": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "Идентификатор ресурса (namespace/name).",
|
||||||
|
},
|
||||||
|
"name": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя TimeTrigger.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"namespace": schema.StringAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Description: "Kubernetes namespace.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"function": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя Fission Function для запуска.",
|
||||||
|
},
|
||||||
|
"cron": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "CRON-расписание, например \"*/10 * * * *\".",
|
||||||
|
},
|
||||||
|
"uid": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "UID объекта в Kubernetes.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||||
|
if req.ProviderData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, ok := req.ProviderData.(*client.Client)
|
||||||
|
if !ok {
|
||||||
|
resp.Diagnostics.AddError("Неверный тип провайдера", fmt.Sprintf("ожидался *client.Client, получен %T", req.ProviderData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.client = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||||
|
var plan cronTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := plan.Namespace.ValueString()
|
||||||
|
if ns == "" {
|
||||||
|
ns = r.client.Namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
obj := buildTimeTrigger(plan.Name.ValueString(), ns, plan.Function.ValueString(), plan.Cron.ValueString())
|
||||||
|
created, err := r.client.CreateTimeTrigger(ctx, obj)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка создания CRON trigger", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(ns + "/" + plan.Name.ValueString())
|
||||||
|
plan.Namespace = types.StringValue(ns)
|
||||||
|
plan.UID = types.StringValue(string(created.GetUID()))
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||||
|
var state cronTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
obj, err := r.client.GetTimeTrigger(ctx, state.Namespace.ValueString(), state.Name.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
if client.IsNotFound(err) {
|
||||||
|
resp.State.RemoveResource(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Diagnostics.AddError("Ошибка чтения CRON trigger", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
spec, _, _ := unstructured.NestedMap(obj.Object, "spec")
|
||||||
|
if cron, ok := spec["cron"].(string); ok {
|
||||||
|
state.Cron = types.StringValue(cron)
|
||||||
|
}
|
||||||
|
if fnRef, ok := spec["functionref"].(map[string]any); ok {
|
||||||
|
if name, ok := fnRef["name"].(string); ok {
|
||||||
|
state.Function = types.StringValue(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.UID = types.StringValue(string(obj.GetUID()))
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||||
|
var plan cronTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
existing, err := r.client.GetTimeTrigger(ctx, plan.Namespace.ValueString(), plan.Name.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка получения CRON trigger для обновления", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := unstructured.SetNestedField(existing.Object, plan.Cron.ValueString(), "spec", "cron"); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка обновления cron", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := unstructured.SetNestedField(existing.Object, plan.Function.ValueString(), "spec", "functionref", "name"); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка обновления function", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := r.client.UpdateTimeTrigger(ctx, existing)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка обновления CRON trigger", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.UID = types.StringValue(string(updated.GetUID()))
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *CronTriggerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||||
|
var state cronTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.client.DeleteTimeTrigger(ctx, state.Namespace.ValueString(), state.Name.ValueString()); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка удаления CRON trigger", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildTimeTrigger(name, ns, functionName, cron string) *unstructured.Unstructured {
|
||||||
|
return &unstructured.Unstructured{
|
||||||
|
Object: map[string]any{
|
||||||
|
"apiVersion": "fission.io/v1",
|
||||||
|
"kind": "TimeTrigger",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": name,
|
||||||
|
"namespace": ns,
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"cron": cron,
|
||||||
|
"functionref": map[string]any{
|
||||||
|
"type": "name",
|
||||||
|
"name": functionName,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/attr"
|
||||||
|
"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/mapdefault"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
|
||||||
|
"terraform-provider-fission/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ resource.Resource = &IoTDeviceResource{}
|
||||||
|
|
||||||
|
// IoTDeviceResource управляет CRD IoTDevice (iot.kube5s.ru/v1alpha1).
|
||||||
|
type IoTDeviceResource struct {
|
||||||
|
client *client.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type iotDeviceResourceModel struct {
|
||||||
|
ID types.String `tfsdk:"id"`
|
||||||
|
Name types.String `tfsdk:"name"`
|
||||||
|
Namespace types.String `tfsdk:"namespace"`
|
||||||
|
DeviceID types.String `tfsdk:"device_id"`
|
||||||
|
Enabled types.Bool `tfsdk:"enabled"`
|
||||||
|
Metadata types.Map `tfsdk:"metadata"`
|
||||||
|
Phase types.String `tfsdk:"phase"`
|
||||||
|
MQTTUsername types.String `tfsdk:"mqtt_username"`
|
||||||
|
SecretName types.String `tfsdk:"secret_name"`
|
||||||
|
TopicPrefix types.String `tfsdk:"topic_prefix"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewIoTDeviceResource() resource.Resource {
|
||||||
|
return &IoTDeviceResource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||||
|
resp.TypeName = req.ProviderTypeName + "_iot_device"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
|
resp.Schema = schema.Schema{
|
||||||
|
Description: "IoT-устройство, зарегистрированное в платформе sless (CRD iot.kube5s.ru/v1alpha1/IoTDevice).",
|
||||||
|
Attributes: map[string]schema.Attribute{
|
||||||
|
"id": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "Идентификатор ресурса (namespace/name).",
|
||||||
|
},
|
||||||
|
"name": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя IoTDevice.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"namespace": schema.StringAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: stringdefault.StaticString("sless"),
|
||||||
|
Description: "Namespace IoTDevice. По умолчанию sless.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"device_id": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Уникальный ID устройства внутри namespace (строчные буквы, цифры, дефис).",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"enabled": schema.BoolAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: booldefault.StaticBool(true),
|
||||||
|
Description: "Активно ли устройство.",
|
||||||
|
},
|
||||||
|
"metadata": schema.MapAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
ElementType: types.StringType,
|
||||||
|
Default: mapdefault.StaticValue(types.MapValueMust(types.StringType, map[string]attr.Value{})),
|
||||||
|
Description: "Произвольные метаданные устройства (модель, локация и т.д.).",
|
||||||
|
},
|
||||||
|
// Computed (заполняет iot-operator)
|
||||||
|
"phase": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "Текущая фаза: Active, Disabled, Pending, Error.",
|
||||||
|
},
|
||||||
|
"mqtt_username": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "MQTT username, выданный iot-operator.",
|
||||||
|
},
|
||||||
|
"secret_name": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "Имя K8s Secret с MQTT credentials.",
|
||||||
|
},
|
||||||
|
"topic_prefix": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "MQTT topic prefix для публикации.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||||
|
if req.ProviderData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, ok := req.ProviderData.(*client.Client)
|
||||||
|
if !ok {
|
||||||
|
resp.Diagnostics.AddError("Неверный тип провайдера", fmt.Sprintf("ожидался *client.Client, получен %T", req.ProviderData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.client = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||||
|
var plan iotDeviceResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := plan.Namespace.ValueString()
|
||||||
|
meta := map[string]string{}
|
||||||
|
if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() {
|
||||||
|
elems := plan.Metadata.Elements()
|
||||||
|
for k, v := range elems {
|
||||||
|
if sv, ok := v.(types.String); ok {
|
||||||
|
meta[k] = sv.ValueString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := r.client.CreateIoTDevice(ctx, plan.Name.ValueString(), ns, plan.DeviceID.ValueString(), meta)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка создания IoT device", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(ns + "/" + plan.Name.ValueString())
|
||||||
|
plan.Phase = types.StringValue(info.Phase)
|
||||||
|
plan.MQTTUsername = types.StringValue(info.MQTTUsername)
|
||||||
|
plan.SecretName = types.StringValue(info.SecretName)
|
||||||
|
plan.TopicPrefix = types.StringValue(info.TopicPrefix)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||||
|
var state iotDeviceResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := r.client.GetIoTDevice(ctx, state.Namespace.ValueString(), state.Name.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
if client.IsNotFound(err) {
|
||||||
|
resp.State.RemoveResource(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Diagnostics.AddError("Ошибка чтения IoT device", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Phase = types.StringValue(info.Phase)
|
||||||
|
state.MQTTUsername = types.StringValue(info.MQTTUsername)
|
||||||
|
state.SecretName = types.StringValue(info.SecretName)
|
||||||
|
state.TopicPrefix = types.StringValue(info.TopicPrefix)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||||
|
// name/namespace/device_id — RequiresReplace, значит Update только для enabled/metadata.
|
||||||
|
// Для простоты: пересоздаём объект.
|
||||||
|
var plan iotDeviceResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := plan.Namespace.ValueString()
|
||||||
|
name := plan.Name.ValueString()
|
||||||
|
|
||||||
|
if err := r.client.DeleteIoTDevice(ctx, ns, name); err != nil && !client.IsNotFound(err) {
|
||||||
|
resp.Diagnostics.AddError("Ошибка удаления IoT device при обновлении", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := map[string]string{}
|
||||||
|
if !plan.Metadata.IsNull() && !plan.Metadata.IsUnknown() {
|
||||||
|
elems := plan.Metadata.Elements()
|
||||||
|
for k, v := range elems {
|
||||||
|
if sv, ok := v.(types.String); ok {
|
||||||
|
meta[k] = sv.ValueString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := r.client.CreateIoTDevice(ctx, name, ns, plan.DeviceID.ValueString(), meta)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка создания IoT device при обновлении", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.Phase = types.StringValue(info.Phase)
|
||||||
|
plan.MQTTUsername = types.StringValue(info.MQTTUsername)
|
||||||
|
plan.SecretName = types.StringValue(info.SecretName)
|
||||||
|
plan.TopicPrefix = types.StringValue(info.TopicPrefix)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *IoTDeviceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||||
|
var state iotDeviceResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := r.client.DeleteIoTDevice(ctx, state.Namespace.ValueString(), state.Name.ValueString()); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка удаления IoT device", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package resources
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringdefault"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
|
||||||
|
"github.com/hashicorp/terraform-plugin-framework/types"
|
||||||
|
|
||||||
|
"terraform-provider-fission/internal/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
var _ resource.Resource = &MQTriggerResource{}
|
||||||
|
|
||||||
|
// MQTriggerResource управляет MQ-триггером через K8s Deployment+Secret (sqs-consumer).
|
||||||
|
type MQTriggerResource struct {
|
||||||
|
client *client.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
type mqTriggerResourceModel struct {
|
||||||
|
ID types.String `tfsdk:"id"`
|
||||||
|
Name types.String `tfsdk:"name"`
|
||||||
|
Namespace types.String `tfsdk:"namespace"`
|
||||||
|
Function types.String `tfsdk:"function"`
|
||||||
|
Queue types.String `tfsdk:"queue"`
|
||||||
|
AccessKey types.String `tfsdk:"access_key"`
|
||||||
|
SecretKey types.String `tfsdk:"secret_key"`
|
||||||
|
SQSEndpoint types.String `tfsdk:"sqs_endpoint"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewMQTriggerResource() resource.Resource {
|
||||||
|
return &MQTriggerResource{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
|
||||||
|
resp.TypeName = req.ProviderTypeName + "_mq_trigger"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
|
||||||
|
resp.Schema = schema.Schema{
|
||||||
|
Description: "MQ-триггер через K8s Deployment (sqs-consumer) + Secret с SQS credentials.",
|
||||||
|
Attributes: map[string]schema.Attribute{
|
||||||
|
"id": schema.StringAttribute{
|
||||||
|
Computed: true,
|
||||||
|
Description: "Идентификатор ресурса (namespace/name).",
|
||||||
|
},
|
||||||
|
"name": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя MQ trigger.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"namespace": schema.StringAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Description: "Kubernetes namespace.",
|
||||||
|
PlanModifiers: []planmodifier.String{
|
||||||
|
stringplanmodifier.RequiresReplace(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"function": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя Fission Function, к которой направляются сообщения из очереди.",
|
||||||
|
},
|
||||||
|
"queue": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Description: "Имя SQS очереди.",
|
||||||
|
},
|
||||||
|
"access_key": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Sensitive: true,
|
||||||
|
Description: "SQS Access Key.",
|
||||||
|
},
|
||||||
|
"secret_key": schema.StringAttribute{
|
||||||
|
Required: true,
|
||||||
|
Sensitive: true,
|
||||||
|
Description: "SQS Secret Key.",
|
||||||
|
},
|
||||||
|
"sqs_endpoint": schema.StringAttribute{
|
||||||
|
Optional: true,
|
||||||
|
Computed: true,
|
||||||
|
Default: stringdefault.StaticString("http://shared-sqs.shared-sqs.svc.cluster.local:4100"),
|
||||||
|
Description: "SQS endpoint URL. По умолчанию — внутренний shared-sqs.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
|
||||||
|
if req.ProviderData == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c, ok := req.ProviderData.(*client.Client)
|
||||||
|
if !ok {
|
||||||
|
resp.Diagnostics.AddError("Неверный тип провайдера", fmt.Sprintf("ожидался *client.Client, получен %T", req.ProviderData))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r.client = c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
|
||||||
|
var plan mqTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := plan.Namespace.ValueString()
|
||||||
|
if ns == "" {
|
||||||
|
ns = r.client.Namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := r.client.CreateMQTrigger(
|
||||||
|
ctx,
|
||||||
|
plan.Name.ValueString(),
|
||||||
|
ns,
|
||||||
|
plan.Function.ValueString(),
|
||||||
|
plan.Queue.ValueString(),
|
||||||
|
plan.SQSEndpoint.ValueString(),
|
||||||
|
plan.AccessKey.ValueString(),
|
||||||
|
plan.SecretKey.ValueString(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка создания MQ trigger", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.ID = types.StringValue(ns + "/" + plan.Name.ValueString())
|
||||||
|
plan.Namespace = types.StringValue(ns)
|
||||||
|
plan.SQSEndpoint = types.StringValue(info.SQSEndpoint)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) {
|
||||||
|
var state mqTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := state.Namespace.ValueString()
|
||||||
|
info, err := r.client.GetMQTrigger(ctx, ns, state.Name.ValueString())
|
||||||
|
if err != nil {
|
||||||
|
if client.IsNotFound(err) {
|
||||||
|
resp.State.RemoveResource(ctx)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp.Diagnostics.AddError("Ошибка чтения MQ trigger", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state.Function = types.StringValue(info.Function)
|
||||||
|
state.Queue = types.StringValue(info.Queue)
|
||||||
|
state.SQSEndpoint = types.StringValue(info.SQSEndpoint)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &state)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) {
|
||||||
|
var plan mqTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ns := plan.Namespace.ValueString()
|
||||||
|
name := plan.Name.ValueString()
|
||||||
|
|
||||||
|
// Удаляем старый и создаём заново с новыми параметрами.
|
||||||
|
if err := r.client.DeleteMQTrigger(ctx, ns, name); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка удаления MQ trigger при обновлении", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, err := r.client.CreateMQTrigger(
|
||||||
|
ctx, name, ns,
|
||||||
|
plan.Function.ValueString(),
|
||||||
|
plan.Queue.ValueString(),
|
||||||
|
plan.SQSEndpoint.ValueString(),
|
||||||
|
plan.AccessKey.ValueString(),
|
||||||
|
plan.SecretKey.ValueString(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка создания MQ trigger при обновлении", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.SQSEndpoint = types.StringValue(info.SQSEndpoint)
|
||||||
|
resp.Diagnostics.Append(resp.State.Set(ctx, &plan)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *MQTriggerResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) {
|
||||||
|
var state mqTriggerResourceModel
|
||||||
|
resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
|
||||||
|
if resp.Diagnostics.HasError() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.client.DeleteMQTrigger(ctx, state.Namespace.ValueString(), state.Name.ValueString()); err != nil {
|
||||||
|
resp.Diagnostics.AddError("Ошибка удаления MQ trigger", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user