diff --git a/.github/workflows/go-build.yml b/.github/workflows/go-build.yml new file mode 100644 index 0000000..a06aded --- /dev/null +++ b/.github/workflows/go-build.yml @@ -0,0 +1,29 @@ +name: Go Build + +on: + push: + branches: + - main + - develop + - feature/** + pull_request: + branches: + - main + - develop + - feature/** + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: '1.22' # Use the Go version appropriate for your project + + - name: Build + run: go build -v ./... diff --git a/.gitignore b/.gitignore index c67b8b3..d305eb7 100644 --- a/.gitignore +++ b/.gitignore @@ -53,8 +53,11 @@ localstack-data/ tmp/ temp/ localstack-data/ +vendor/ aws/ sample/ keycloak/managed_context -keycloak/test_suite_analysis \ No newline at end of file +keycloak/test_suite_analysis +.prompts/ +app-config-prod.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..833e9f9 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +ARG GO_VERSION=1.24.2 +FROM golang:${GO_VERSION}-alpine AS builder +RUN mkdir /user && \ + echo 'nobody:x:65534:65534:nobody:/:' > /user/passwd && \ + echo 'nobody:x:65534:' > /user/group +RUN apk add --no-cache ca-certificates +WORKDIR /src +COPY go.mod go.sum ./ +RUN go mod vendor +COPY ./ ./ +RUN CGO_ENABLED=0 GOFLAGS=-mod=vendor GOOS=linux go build -a -o /app . + +FROM alpine:latest AS final +RUN apk add --no-cache ca-certificates curl +COPY --from=builder /user/group /user/passwd /etc/ +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=builder /app /app +USER nobody:nobody + +EXPOSE 8080 +ENTRYPOINT ["/app"] diff --git a/cmd/serve.go b/cmd/serve.go index 8892624..f7aa059 100644 --- a/cmd/serve.go +++ b/cmd/serve.go @@ -2,7 +2,6 @@ package cmd import ( "NotificationManagement/config" - "NotificationManagement/conn" "NotificationManagement/logger" "NotificationManagement/server" "context" @@ -23,16 +22,15 @@ var serveCmd = &cobra.Command{ Long: `Start the notification management server with the specified configuration`, Run: func(cmd *cobra.Command, args []string) { app := fx.New( - fx.Provide(conn.NewDB), server.Module, fx.Invoke(func(lc fx.Lifecycle, e *echo.Echo) { - e.GET("/health", func(c echo.Context) error { + e.GET("/api/health", func(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) }) // Add Prometheus middleware e.Use(echoprometheus.NewMiddleware("notification_management")) - e.GET("/metrics", echoprometheus.NewHandler()) + e.GET("/api/metrics", echoprometheus.NewHandler()) port := *config.App().Port addr := fmt.Sprintf(":%d", port) diff --git a/cmd/worker.go b/cmd/worker.go index 2bfb969..ffce359 100644 --- a/cmd/worker.go +++ b/cmd/worker.go @@ -83,7 +83,7 @@ func registerHooks(telegramAPI domain.TelegramAPI) { func NewAsynqServer() *asynq.Server { return asynq.NewServer( asynq.RedisClientOpt{ - Addr: config.Asynq().RedisAddr, + Addr: config.GetRedisAddr(), DB: *config.Asynq().DB, Password: config.Asynq().Pass, }, diff --git a/config/aws_client.go b/config/aws_client.go index ba5b8cb..dd52ec5 100644 --- a/config/aws_client.go +++ b/config/aws_client.go @@ -20,6 +20,11 @@ type AWSClient struct { } func NewAWSClient(cnf *AWSConfig) (*AWSClient, error) { + + if os.Getenv(EnvAWSConfigServiceEnabled) == "false" { + return nil, fmt.Errorf("aws service not available") + } + var creds aws.CredentialsProvider if cnf.AccessKeyID != "" && cnf.SecretAccessKey != "" { creds = credentials.NewStaticCredentialsProvider( @@ -94,9 +99,6 @@ func (c *AWSClient) GetConfigurationRecorderStatus(ctx context.Context) (*config } func (c *AWSClient) loadFromSsm() { - if os.Getenv(EnvConfigFromSSM) == "false" { - return - } resp, err := c.ssm.GetParameter(context.TODO(), &ssm.GetParameterInput{ Name: &c.awsConfig.ConfigService.SSM, WithDecryption: aws.Bool(true), diff --git a/config/config.go b/config/config.go index 90991ca..8c93055 100644 --- a/config/config.go +++ b/config/config.go @@ -42,7 +42,7 @@ type DatabaseConfig struct { } type AsynqConfig struct { - RedisAddr string `mapstructure:"redisaddr"` + //RedisAddr string `mapstructure:"redisaddr"` DB *int `mapstructure:"db"` Pass string `mapstructure:"pass"` Concurrency *int `mapstructure:"concurrency"` @@ -173,7 +173,7 @@ func loadDefaults() *Config { SSLMode: "disable", }, Asynq: AsynqConfig{ - RedisAddr: "127.0.0.1:6379", + //RedisAddr: "127.0.0.1:6379", DB: helper.ToInt("15"), Pass: "*****", Concurrency: helper.ToInt("10"), diff --git a/conn/asynq.go b/conn/asynq.go index aee442f..a68a8a6 100644 --- a/conn/asynq.go +++ b/conn/asynq.go @@ -8,7 +8,7 @@ import ( func NewAsynq() *asynq.Client { asynqConfig := config.Asynq() return asynq.NewClient(asynq.RedisClientOpt{ - Addr: asynqConfig.RedisAddr, + Addr: config.GetRedisAddr(), DB: *asynqConfig.DB, Password: asynqConfig.Pass, }) @@ -17,7 +17,7 @@ func NewAsynq() *asynq.Client { func NewAsynqInspector() *asynq.Inspector { asynqConfig := config.Asynq() return asynq.NewInspector(asynq.RedisClientOpt{ - Addr: asynqConfig.RedisAddr, + Addr: config.GetRedisAddr(), DB: *asynqConfig.DB, Password: asynqConfig.Pass, }) diff --git a/deploy-kubernates.sh b/deploy-kubernates.sh new file mode 100644 index 0000000..b550a03 --- /dev/null +++ b/deploy-kubernates.sh @@ -0,0 +1,89 @@ +#!/bin/bash + +PROJECT_BASE_PATH=$(pwd) + +# Check for --del argument +if [[ "$1" == "--del" ]]; then + echo "Deleting all Kubernetes resources..." + kubectl delete deployment,service,configmap,secret,ingress,statefulset --all -n default + echo "Kubernetes resources deleted." + exit 0 +fi + +SECRETS_SED_COMMANDS="" +CONFIGMAP_SED_COMMANDS="" + +while IFS='=' read -r key value; do + if [[ -z "$key" ]]; then + continue + fi + + if [[ "$key" == "GEMINI_KEY" || "$key" == "TELEGRAM_TOKEN" ]]; then + if [[ -n "${!key}" ]]; then + value="${!key}" + echo "Using environment variable for $key" + else + echo "Warning: $key not found in environment variables. Using value from .env file." + fi + fi + + if [[ -z "$value" ]]; then + continue + fi + + if grep -q " $key:" k8/secrets.yaml; then + ENCODED_VALUE=$(echo -n "$value" | base64) + SECRETS_SED_COMMANDS+=" -e \"s| $key: \".*\"| $key: \\\"$ENCODED_VALUE\\\"|g\"" + SECRETS_SED_COMMANDS+=" -e \"s| $key: .*| $key: \\\"$ENCODED_VALUE\\\"|g\"" + fi + + if grep -q " $key:" k8/config-maps.yaml; then + CONFIGMAP_SED_COMMANDS+=" -e \"s|^[[:space:]]*$key:.*$| $key: \\\"$value\\\"|g\"" + fi +done < .env + +if [[ -n "$SECRETS_SED_COMMANDS" ]]; then + echo "Updating k8/secrets.yaml with values from .env..." + eval "cat k8/secrets.yaml | sed $SECRETS_SED_COMMANDS" | kubectl apply -f - +else + echo "No matching secrets found in .env to update k8/secrets.yaml." + kubectl apply -f k8/secrets.yaml +fi + +if [[ -n "$CONFIGMAP_SED_COMMANDS" ]]; then + echo "Updating k8/config-maps.yaml with values from .env..." + eval "cat k8/config-maps.yaml | sed $CONFIGMAP_SED_COMMANDS" | kubectl apply -f - +else + echo "No matching config map values found in .env to update k8/config-maps.yaml." + kubectl apply -f k8/config-maps.yaml +fi + +for file in k8/*.yaml; do + if [ "$file" == "k8/secrets.yaml" ] || [ "$file" == "k8/config-maps.yaml" ]; then + echo "Skipping $file as it's already processed." + continue + fi + + if grep -q "__PROJECT_BASE_PATH__" "$file"; then + echo "Processing $file..." + sed "s|__PROJECT_BASE_PATH__|$PROJECT_BASE_PATH|g" "$file" | kubectl apply -f - + else + echo "Applying $file without path replacement..." + kubectl apply -f "$file" + fi +done +echo "All Kubernetes manifests in 'k8' directory processed with dynamic path: $PROJECT_BASE_PATH" + +echo "Waiting for config-server statefulset to be ready..." +kubectl rollout status --watch --timeout=300s statefulset/config-server + +CONFIG_SERVER_POD=$(kubectl get pods -l app=config-server -o jsonpath='{.items[0].metadata.name}') + +if [ -z "$CONFIG_SERVER_POD" ]; then + echo "Error: config-server pod not found." + exit 1 +fi + +kubectl cp env/app-config-prod.json "$CONFIG_SERVER_POD":/tmp/app-config.json +kubectl cp env/aws_export.sh "$CONFIG_SERVER_POD":/tmp/aws_export.sh +kubectl exec "$CONFIG_SERVER_POD" -- bash /tmp/aws_export.sh; diff --git a/docker-compose.yml b/docker-compose.yml index c25de3d..3823eb2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,20 +48,22 @@ services: networks: - nms-network - keycloak: - container_name: keycloak + keycloak_svc: + container_name: gocloak image: keycloak/keycloak:25.0.6 command: - start-dev - --import-realm environment: - KC_HOSTNAME: localhost + KC_HOSTNAME: $KEYCLOAK_SERVER_URL + KC_HOSTNAME_STRICT: true KEYCLOAK_USER: admin KEYCLOAK_PASSWORD: secret KEYCLOAK_ADMIN: admin KEYCLOAK_ADMIN_PASSWORD: secret KC_HEALTH_ENABLED: "true" KC_FEATURES: account-api,account3,authorization,client-policies,impersonation,docker,scripts,admin-fine-grained-authz + KC_HTTP_RELATIVE_PATH: /keycloak ports: - "8081:8080" - "9000:9000" @@ -76,12 +78,12 @@ services: redis: image: "bitnami/redis:6.0.9" platform: linux/amd64 # Force x86 architecture for compatibility - container_name: nms-redis + container_name: redis restart: unless-stopped ports: - "6379:6379" environment: -# - REDIS_PASSWORD= + #- REDIS_PASSWORD= - ALLOW_EMPTY_PASSWORD=yes volumes: - nms_redis_local_db:/bitnami/redis/data @@ -116,6 +118,7 @@ services: - '--storage.tsdb.path=/prometheus' - '--web.console.libraries=/usr/share/prometheus/console_libraries' - '--web.console.templates=/usr/share/prometheus/consoles' + # - '--web.external-url=/prometheus' extra_hosts: - "host.docker.internal:host-gateway" # Required for Linux to resolve host.docker.internal networks: @@ -129,6 +132,8 @@ services: environment: - GF_SECURITY_ADMIN_USER=admin - GF_SECURITY_ADMIN_PASSWORD=admin + # - GF_SERVER_ROOT_URL=http://localhost:3000/grafana + # - GF_SERVER_SERVE_FROM_SUB_PATH=true volumes: - grafana-data:/var/lib/grafana networks: @@ -136,6 +141,63 @@ services: depends_on: - prometheus + app: + build: + context: . + dockerfile: Dockerfile + container_name: go-nms + image: tuhin47/go-nms:lastest + command: + - serve + #restart: unless-stopped + ports: + - "8080:8080" + environment: + - LOG_LEVEL + - TELEGRAM_TOKEN + - GEMINI_KEY + - AWS_ENDPOINT + - AWS_CONFIG_SERVICE_ENABLED + - REDIS_HOST + - DB_HOST + - DB_PORT + - KEYCLOAK_SERVER_URL + volumes: + - ./app.log:/app.log + depends_on: + config-server: + condition: service_healthy + redis: + condition: service_started + networks: + - nms-network + extra_hosts: + - "keycloak:host-gateway" + + worker: + container_name: go-nms-worker + image: tuhin47/go-nms:lastest + command: + - worker + restart: unless-stopped + environment: + - LOG_LEVEL + - TELEGRAM_TOKEN + - GEMINI_KEY + - AWS_ENDPOINT + - AWS_CONFIG_SERVICE_ENABLED + - REDIS_HOST + - DB_HOST + - DB_PORT + volumes: + - ./app.log:/app.log + networks: + - nms-network + depends_on: + app: + condition: service_started + redis: + condition: service_started networks: nms-network: driver: bridge diff --git a/domain/repository.go b/domain/repository.go index 7f851fd..9f6faef 100644 --- a/domain/repository.go +++ b/domain/repository.go @@ -2,11 +2,13 @@ package domain import ( "context" + "gorm.io/gorm" ) type Repository[T any, ID comparable] interface { GetDB(ctx context.Context) *gorm.DB + WithTx(tx *gorm.DB) Repository[T, ID] Create(ctx context.Context, entity *T) error GetByID(ctx context.Context, id ID, preloads *[]string) (*T, error) GetByIDs(ctx context.Context, ids []uint, preloads *[]string) ([]T, error) diff --git a/domain/service.go b/domain/service.go index 9f98661..0cf7dc1 100644 --- a/domain/service.go +++ b/domain/service.go @@ -11,5 +11,5 @@ type CommonService[T any] interface { UpdateModel(c context.Context, id uint, model *T) (*T, error) DeleteModel(c context.Context, id uint) error GetInstance() CommonService[T] - GetContext() context.Context + ProcessContext(context.Context) context.Context } diff --git a/domain/user.go b/domain/user.go index d0e99a3..feda944 100644 --- a/domain/user.go +++ b/domain/user.go @@ -8,12 +8,11 @@ import ( type UserRepository interface { Repository[models.User, uint] FindByKeycloakID(keycloakID string, ctx context.Context) (*models.User, error) - FindByEmail(email string) (*models.User, error) } type UserService interface { CommonService[models.User] - RegisterOrUpdateUser(user *models.User) (*models.User, error) + RegisterOrUpdateUser(ctx context.Context, user *models.User) (*models.User, error) } type UserController interface { diff --git a/env/aws_export.sh b/env/aws_export.sh new file mode 100644 index 0000000..482c779 --- /dev/null +++ b/env/aws_export.sh @@ -0,0 +1,20 @@ +echo "🔧 Setting up environment variables..." +export AWS_ENDPOINT=http://localhost:4566 +export AWS_REGION=us-east-1 +export AWS_ACCESS_KEY_ID=test +export AWS_SECRET_ACCESS_KEY=test + +echo "📋 Environment variables set:" +echo " AWS_ENDPOINT=$AWS_ENDPOINT" +echo " AWS_REGION=$AWS_REGION" + +export CONFIG_SSM_PARAM="/myapp/config" +export CONFIG_FROM_SSM=true + +aws --endpoint-url=http://localhost:4566 ssm put-parameter \ + --name "$CONFIG_SSM_PARAM" \ + --region "$AWS_REGION" \ + --type "String" \ + --value "$(cat /tmp/app-config.json)" \ + --overwrite + diff --git a/k8/app.yaml b/k8/app.yaml new file mode 100644 index 0000000..2864ef6 --- /dev/null +++ b/k8/app.yaml @@ -0,0 +1,59 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: app + labels: + app: app +spec: + replicas: 1 + selector: + matchLabels: + app: app + template: + metadata: + labels: + app: app + spec: + containers: + - name: app + image: tuhin47/go-nms:lastest + imagePullPolicy: IfNotPresent + command: ["/app"] + args: ["serve"] + ports: + - containerPort: 8080 + env: + - name: TELEGRAM_TOKEN + valueFrom: + secretKeyRef: + name: nms-secrets + key: TELEGRAM_TOKEN + - name: GEMINI_KEY + valueFrom: + secretKeyRef: + name: nms-secrets + key: GEMINI_KEY + envFrom: + - configMapRef: + name: nms-config + volumeMounts: + - name: app-log + mountPath: /app.log + volumes: + - name: app-log + emptyDir: {} # Or a persistent volume if logs need to persist +--- +apiVersion: v1 +kind: Service +metadata: + name: app + labels: + app: app +spec: + selector: + app: app + ports: + - protocol: TCP + port: 8080 + targetPort: 8080 + type: ClusterIP diff --git a/k8/config-maps.yaml b/k8/config-maps.yaml new file mode 100644 index 0000000..b3df376 --- /dev/null +++ b/k8/config-maps.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: nms-config +data: + AWS_CONFIG_SERVICE_ENABLED: "false" + KC_HOSTNAME: "hostname" + AWS_ENDPOINT: "http://hostname:4566" + KEYCLOAK_SERVER_URL: "http://hostname:8080/keycloak" + LOG_LEVEL: "" + GF_SERVER_ROOT_URL: "http://hostname:3000/grafana" diff --git a/k8/config-server.yaml b/k8/config-server.yaml new file mode 100644 index 0000000..83e5e79 --- /dev/null +++ b/k8/config-server.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: config-server + labels: + app: config-server +spec: + serviceName: "config-server" + replicas: 1 + selector: + matchLabels: + app: config-server + template: + metadata: + labels: + app: config-server + spec: + containers: + - name: config-server + image: localstack/localstack:3.2.0 + ports: + - containerPort: 4566 + env: + - name: SERVICES + value: "ssm,config" + - name: DEBUG + value: "1" + - name: PERSISTENCE + value: "1" + - name: DATA_DIR + value: "/tmp/localstack/data" + - name: LAMBDA_EXECUTOR + value: "docker" + volumeMounts: + - name: docker-sock + mountPath: /var/run/docker.sock + - name: config-data + mountPath: /var/lib/localstack + volumes: + - name: docker-sock + hostPath: + path: /var/run/docker.sock + volumeClaimTemplates: + - metadata: + name: config-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi + +--- +apiVersion: v1 +kind: Service +metadata: + name: config-server + labels: + app: config-server +spec: + selector: + app: config-server + ports: + - protocol: TCP + port: 4566 + targetPort: 4566 + type: ClusterIP diff --git a/k8/grafana.yaml b/k8/grafana.yaml new file mode 100644 index 0000000..afd53a6 --- /dev/null +++ b/k8/grafana.yaml @@ -0,0 +1,63 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: grafana + labels: + app: grafana +spec: + serviceName: "grafana" + replicas: 1 + selector: + matchLabels: + app: grafana + template: + metadata: + labels: + app: grafana + spec: + containers: + - name: grafana + image: grafana/grafana:12.0.2 + ports: + - containerPort: 3000 + env: + - name: GF_SECURITY_ADMIN_USER + value: "admin" + - name: GF_SERVER_ROOT_URL + valueFrom: + configMapKeyRef: + name: nms-config + key: GF_SERVER_ROOT_URL + - name: GF_SERVER_SERVE_FROM_SUB_PATH + value: "true" + - name: GF_SECURITY_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: nms-secrets + key: GF_SECURITY_ADMIN_PASSWORD + volumeMounts: + - name: grafana-data + mountPath: /var/lib/grafana + volumeClaimTemplates: + - metadata: + name: grafana-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: grafana + labels: + app: grafana +spec: + selector: + app: grafana + ports: + - protocol: TCP + port: 3000 + targetPort: 3000 + type: ClusterIP diff --git a/k8/ingress-nginx-controller.yaml b/k8/ingress-nginx-controller.yaml new file mode 100644 index 0000000..7c4ae48 --- /dev/null +++ b/k8/ingress-nginx-controller.yaml @@ -0,0 +1,266 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: ingress-nginx + +--- +apiVersion: v1 +kind: ServiceAccount +metadata: + name: nginx-ingress-serviceaccount + namespace: ingress-nginx + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: nginx-ingress-clusterrole +rules: + - apiGroups: + - "" + resources: + - configmaps + - endpoints + - nodes + - pods + - secrets + verbs: + - list + - watch + - apiGroups: + - "" + resources: + - nodes + verbs: + - get + - apiGroups: + - "" + resources: + - services + verbs: + - get + - list + - watch + - apiGroups: + - extensions + - networking.k8s.io + resources: + - ingresses + verbs: + - get + - list + - watch + - apiGroups: + - "" + resources: + - events + verbs: + - create + - patch + - apiGroups: + - extensions + - networking.k8s.io + resources: + - ingresses/status + verbs: + - update + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: nginx-ingress-role + namespace: ingress-nginx +rules: + - apiGroups: + - "" + resources: + - configmaps + - pods + - secrets + - namespaces + verbs: + - get + - apiGroups: + - "" + resources: + - configmaps + resourceNames: + - "ingress-controller-leader-nginx" + verbs: + - get + - update + - apiGroups: + - "" + resources: + - configmaps + verbs: + - create + - apiGroups: + - "" + resources: + - endpoints + verbs: + - get + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: nginx-ingress-role-nisa-binding + namespace: ingress-nginx +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: nginx-ingress-role +subjects: + - kind: ServiceAccount + name: nginx-ingress-serviceaccount + namespace: ingress-nginx + +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: nginx-ingress-clusterrole-nisa-binding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: nginx-ingress-clusterrole +subjects: + - kind: ServiceAccount + name: nginx-ingress-serviceaccount + namespace: ingress-nginx + +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-ingress-controller + namespace: ingress-nginx + labels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + template: + metadata: + labels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + annotations: + prometheus.io/port: "10254" + prometheus.io/scrape: "true" + spec: + serviceAccountName: nginx-ingress-serviceaccount + containers: + - name: nginx-ingress-controller + image: k8s.gcr.io/ingress-nginx/controller:v1.1.3 + args: + - /nginx-ingress-controller + - --configmap=$(POD_NAMESPACE)/nginx-configuration + - --tcp-services-configmap=$(POD_NAMESPACE)/tcp-services + - --udp-services-configmap=$(POD_NAMESPACE)/udp-services + - --publish-service=$(POD_NAMESPACE)/ingress-nginx + - --annotations-prefix=nginx.ingress.kubernetes.io + securityContext: + allowPrivilegeEscalation: true + capabilities: + drop: + - ALL + add: + - NET_BIND_SERVICE + runAsUser: 101 + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + ports: + - name: http + containerPort: 80 + - name: https + containerPort: 443 + livenessProbe: + failureThreshold: 3 + httpGet: + path: /healthz + port: 10254 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 10 + readinessProbe: + failureThreshold: 3 + httpGet: + path: /healthz + port: 10254 + scheme: HTTP + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 10 + +--- +apiVersion: v1 +kind: Service +metadata: + name: ingress-nginx + namespace: ingress-nginx + labels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx +spec: + type: NodePort + ports: + - name: http + port: 80 + targetPort: 80 + protocol: TCP + nodePort: 30080 + - name: https + port: 443 + targetPort: 443 + protocol: TCP + nodePort: 30443 + selector: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: nginx-configuration + namespace: ingress-nginx + labels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: tcp-services + namespace: ingress-nginx + labels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: udp-services + namespace: ingress-nginx + labels: + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx \ No newline at end of file diff --git a/k8/keycloak.yaml b/k8/keycloak.yaml new file mode 100644 index 0000000..315bca3 --- /dev/null +++ b/k8/keycloak.yaml @@ -0,0 +1,79 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: keycloak + labels: + app: keycloak +spec: + serviceName: "keycloak" + replicas: 1 + selector: + matchLabels: + app: keycloak + template: + metadata: + labels: + app: keycloak + spec: + containers: + - name: keycloak + image: keycloak/keycloak:25.0.6 + command: + - /opt/keycloak/bin/kc.sh + - start-dev + - --import-realm + env: + - name: KEYCLOAK_PASSWORD + valueFrom: + secretKeyRef: + name: nms-secrets + key: KEYCLOAK_PASSWORD + - name: KEYCLOAK_ADMIN_PASSWORD + valueFrom: + secretKeyRef: + name: nms-secrets + key: KEYCLOAK_ADMIN_PASSWORD + - name: KC_FEATURES + value: "account-api,account3,authorization,client-policies,impersonation,docker,scripts,admin-fine-grained-authz" + - name: KC_HEALTH_ENABLED + value: "true" + - name: KC_HTTP_RELATIVE_PATH + value: "/keycloak" + - name: KEYCLOAK_ADMIN + value: "admin" + - name: KEYCLOAK_USER + value: "admin" + envFrom: + - configMapRef: + name: nms-config + ports: + - containerPort: 8080 + - containerPort: 9000 + volumeMounts: + - name: keycloak-import + mountPath: /opt/keycloak/data/import + volumes: + - name: keycloak-import + hostPath: + path: __PROJECT_BASE_PATH__/keycloak/import + type: DirectoryOrCreate +--- +apiVersion: v1 +kind: Service +metadata: + name: keycloak + labels: + app: keycloak +spec: + selector: + app: keycloak + ports: + - protocol: TCP + port: 8081 + targetPort: 8080 + name: http + - protocol: TCP + port: 9000 + targetPort: 9000 + name: management + type: ClusterIP diff --git a/k8/mailcatcher.yaml b/k8/mailcatcher.yaml new file mode 100644 index 0000000..cd7c950 --- /dev/null +++ b/k8/mailcatcher.yaml @@ -0,0 +1,48 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mailcatcher + labels: + app: mailcatcher +spec: + replicas: 1 + selector: + matchLabels: + app: mailcatcher + template: + metadata: + labels: + app: mailcatcher + spec: + containers: + - name: mailcatcher + image: schickling/mailcatcher + ports: + - containerPort: 1080 + - containerPort: 1025 + env: + - name: HTTPPATH + value: "/mails" + envFrom: + - configMapRef: + name: nms-config +--- +apiVersion: v1 +kind: Service +metadata: + name: mailcatcher + labels: + app: mailcatcher +spec: + selector: + app: mailcatcher + ports: + - protocol: TCP + port: 1080 + targetPort: 1080 + name: http + - protocol: TCP + port: 1025 + targetPort: 1025 + name: smtp + type: ClusterIP diff --git a/k8/notification-ingress.yaml b/k8/notification-ingress.yaml new file mode 100644 index 0000000..db1bb23 --- /dev/null +++ b/k8/notification-ingress.yaml @@ -0,0 +1,55 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: notification-management-ingress + namespace: default + annotations: + kubernetes.io/ingress.class: "nginx" + # nginx.ingress.kubernetes.io/rewrite-target: /$2 + nginx.ingress.kubernetes.io/use-regex: "true" +spec: + rules: + - http: + paths: + - path: /api(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: app + port: + number: 8080 + # - path: /config(/|$)(.*) + # pathType: ImplementationSpecific + # backend: + # service: + # name: config-server + # port: + # number: 4566 + - path: /grafana(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: grafana + port: + number: 3000 + - path: /keycloak(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: keycloak + port: + number: 8081 # Using the http port for Keycloak + - path: /mails(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: mailcatcher + port: + number: 1080 + - path: /prometheus(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: prometheus + port: + number: 9090 diff --git a/k8/postgres.yaml b/k8/postgres.yaml new file mode 100644 index 0000000..c496cfd --- /dev/null +++ b/k8/postgres.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: postgres + labels: + app: postgres +spec: + serviceName: "postgres" + replicas: 1 + selector: + matchLabels: + app: postgres + template: + metadata: + labels: + app: postgres + spec: + containers: + - name: postgres + image: postgres:15 + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + valueFrom: + secretKeyRef: + name: nms-secrets + key: POSTGRES_PASSWORD + - name: POSTGRES_DB + value: "notification_management" + - name: POSTGRES_USER + value: "user" + envFrom: + - configMapRef: + name: nms-config + volumeMounts: + - name: postgres-data + mountPath: /var/lib/postgresql/data + - name: postgres-initdb + mountPath: /docker-entrypoint-initdb.d/ + volumes: + - name: postgres-initdb + hostPath: + path: __PROJECT_BASE_PATH__/scripts/db + type: DirectoryOrCreate + volumeClaimTemplates: + - metadata: + name: postgres-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres + labels: + app: postgres +spec: + selector: + app: postgres + ports: + - protocol: TCP + port: 5432 + targetPort: 5432 + type: ClusterIP +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: postgres-pv-claim + labels: + app: postgres +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi diff --git a/k8/prometheus.yaml b/k8/prometheus.yaml new file mode 100644 index 0000000..3f39d0c --- /dev/null +++ b/k8/prometheus.yaml @@ -0,0 +1,69 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: prometheus + labels: + app: prometheus +spec: + serviceName: "prometheus" + replicas: 1 + selector: + matchLabels: + app: prometheus + template: + metadata: + labels: + app: prometheus + spec: + containers: + - name: prometheus + image: prom/prometheus:v2.53.5 + ports: + - containerPort: 9090 + volumeMounts: + - name: prometheus-config + mountPath: /etc/prometheus + - name: prometheus-data + mountPath: /prometheus + resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "512Mi" + cpu: "200m" + command: ["prometheus"] + args: + - --config.file=/etc/prometheus/prometheus.yml + - --storage.tsdb.path=/prometheus + - --web.console.libraries=/usr/share/prometheus/console_libraries + - --web.console.templates=/usr/share/prometheus/consoles + - --web.external-url=/prometheus + volumes: + - name: prometheus-config + hostPath: + path: __PROJECT_BASE_PATH__/prometheus + type: DirectoryOrCreate + volumeClaimTemplates: + - metadata: + name: prometheus-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 2Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: prometheus + labels: + app: prometheus +spec: + selector: + app: prometheus + ports: + - protocol: TCP + port: 9090 + targetPort: 9090 + type: ClusterIP diff --git a/k8/redis.yaml b/k8/redis.yaml new file mode 100644 index 0000000..1e1a43b --- /dev/null +++ b/k8/redis.yaml @@ -0,0 +1,51 @@ +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: redis + labels: + app: redis +spec: + serviceName: "redis" + replicas: 1 + selector: + matchLabels: + app: redis + template: + metadata: + labels: + app: redis + spec: + containers: + - name: redis + image: bitnami/redis:6.0.9 + ports: + - containerPort: 6379 + env: + - name: ALLOW_EMPTY_PASSWORD + value: "yes" + volumeMounts: + - name: redis-data + mountPath: /bitnami/redis/data + volumeClaimTemplates: + - metadata: + name: redis-data + spec: + accessModes: [ "ReadWriteOnce" ] + resources: + requests: + storage: 1Gi +--- +apiVersion: v1 +kind: Service +metadata: + name: redis + labels: + app: redis +spec: + selector: + app: redis + ports: + - protocol: TCP + port: 6379 + targetPort: 6379 + type: ClusterIP diff --git a/k8/secrets.yaml b/k8/secrets.yaml new file mode 100644 index 0000000..1b36bfa --- /dev/null +++ b/k8/secrets.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: nms-secrets +type: Opaque +data: + POSTGRES_PASSWORD: "cGFzc3dvcmQ=" + KEYCLOAK_PASSWORD: "c2VjcmV0" + KEYCLOAK_ADMIN_PASSWORD: "c2VjcmV0" + GF_SECURITY_ADMIN_PASSWORD: "YWRtaW4=" + TELEGRAM_TOKEN: "" + GEMINI_KEY: "" diff --git a/k8/worker.yaml b/k8/worker.yaml new file mode 100644 index 0000000..36ce3fc --- /dev/null +++ b/k8/worker.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: worker + labels: + app: worker +spec: + replicas: 1 + selector: + matchLabels: + app: worker + template: + metadata: + labels: + app: worker + spec: + containers: + - name: worker + image: tuhin47/go-nms:lastest + imagePullPolicy: IfNotPresent + command: ["/app"] + args: ["worker"] + resources: + limits: + cpu: "500m" + memory: "512Mi" + requests: + cpu: "250m" + memory: "256Mi" + env: + - name: TELEGRAM_ENABLED + value: "true" + - name: TELEGRAM_TOKEN + valueFrom: + secretKeyRef: + name: nms-secrets + key: TELEGRAM_TOKEN + - name: GEMINI_KEY + valueFrom: + secretKeyRef: + name: nms-secrets + key: GEMINI_KEY + envFrom: + - configMapRef: + name: nms-config + volumeMounts: + - name: app-log + mountPath: /app.log + volumes: + - name: app-log + emptyDir: {} # Or a persistent volume if logs need to persist +--- +apiVersion: v1 +kind: Service +metadata: + name: worker + labels: + app: worker +spec: + selector: + app: worker + ports: + - protocol: TCP + port: 8080 # Placeholder port, as worker doesn't expose one in docker-compose + targetPort: 8080 # Placeholder targetPort + type: ClusterIP diff --git a/keycloak/Dockerfile b/keycloak/Dockerfile deleted file mode 100644 index 71c95d3..0000000 --- a/keycloak/Dockerfile +++ /dev/null @@ -1,12 +0,0 @@ -FROM quay.io/keycloak/keycloak:25.0.1 -COPY . data/import -WORKDIR /opt/keycloak -ENV KC_HOSTNAME=localhost -ENV KEYCLOAK_USER=admin -ENV KEYCLOAK_PASSWORD=secret -ENV KEYCLOAK_ADMIN=admin -ENV KEYCLOAK_ADMIN_PASSWORD=secret -ENV KC_HEALTH_ENABLED=true -ENV KC_FEATURES=account-api,account3,authorization,client-policies,impersonation,docker,scripts,admin-fine-grained-authz -RUN /opt/keycloak/bin/kc.sh import --file /data/import/gocloak-realm.json -ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] \ No newline at end of file diff --git a/middleware/auth.go b/middleware/auth.go index 3060d28..28554a3 100644 --- a/middleware/auth.go +++ b/middleware/auth.go @@ -102,7 +102,7 @@ func KeycloakMiddleware(userService domain.UserService) echo.MiddlewareFunc { Roles: strings.Join(roles, ","), } - user, err := userService.RegisterOrUpdateUser(user) + user, err := userService.RegisterOrUpdateUser(c.Request().Context(), user) if err != nil { return errutil.NewAppError(errutil.ErrUserRegistrationFailed, err) } diff --git a/middleware/transaction.go b/middleware/transaction.go new file mode 100644 index 0000000..7339f3d --- /dev/null +++ b/middleware/transaction.go @@ -0,0 +1,47 @@ +package middleware + +import ( + "NotificationManagement/repositories" + "NotificationManagement/utils/errutil" + "context" + + "github.com/labstack/echo/v4" + "gorm.io/gorm" +) + +func TransactionMiddleware(db *gorm.DB) echo.MiddlewareFunc { + return func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + tx := db.Begin() + if tx.Error != nil { + return tx.Error + } + + // Store the transaction in the context + ctx := GetContext(c, tx) + c.SetRequest(c.Request().WithContext(ctx)) + + // Call the next handler + err := next(c) + + if err != nil { + tx.Rollback() + return errutil.HandleError(c, err) + } + + tx.Commit() + return nil + } + } +} + +func GetContext(c echo.Context, tx *gorm.DB) context.Context { + var ctx context.Context + if c != nil { + ctx = c.Request().Context() + } else { + ctx = context.Background() + } + var filters []*repositories.Filter + return context.WithValue(ctx, repositories.TXContextKey, &repositories.TxContextKey{DB: tx, Filter: filters}) +} diff --git a/minikube.sh b/minikube.sh new file mode 100644 index 0000000..beb9ab1 --- /dev/null +++ b/minikube.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +HOST_DIR=$(pwd) +VM_DIR=$(pwd) +minikube start \ + --mount \ + --mount-string="${HOST_DIR}:${VM_DIR}" \ + --driver=docker \ + --cpus=3 \ + --memory=10g \ + --force + diff --git a/models/ai.go b/models/ai.go index d6a4c6d..9de75ac 100644 --- a/models/ai.go +++ b/models/ai.go @@ -26,7 +26,7 @@ type GeminiModel struct { AIModel `mapper:"inherit"` Name string `gorm:"size:255;not null" json:"name"` ModelName string `gorm:"size:255;not null;check:model_name <> '';index:idx_ai_model_model_secret,unique" json:"model"` - APISecret EncryptedString `gorm:"size:500;index:idx_ai_model_model_secret,unique" json:"api_secret"` + APISecret EncryptedString `gorm:"size:500;index:idx_ai_model_model_secret,unique" json:"-"` } func (d *AIModel) GetType() string { diff --git a/models/telegram.go b/models/telegram.go index 0ad4a02..c919850 100644 --- a/models/telegram.go +++ b/models/telegram.go @@ -7,7 +7,7 @@ import ( type Telegram struct { gorm.Model UserID *uint `gorm:"index"` - User User `gorm:"foreignKey:UserID"` + User User `gorm:"foreignKey:UserID" json:"-"` ChatID int64 `gorm:"uniqueIndex;not null"` Otp string `gorm:"size:255;not null"` } diff --git a/port_forward.sh b/port_forward.sh new file mode 100644 index 0000000..b8729c7 --- /dev/null +++ b/port_forward.sh @@ -0,0 +1,2 @@ +#!/bin/bash +kubectl port-forward service/ingress-nginx 4747:80 -n ingress-nginx \ No newline at end of file diff --git a/prometheus/prometheus.yml b/prometheus/prometheus.yml old mode 100644 new mode 100755 index 5a4ec69..b13c548 --- a/prometheus/prometheus.yml +++ b/prometheus/prometheus.yml @@ -1,11 +1,59 @@ global: - scrape_interval: 15s # Set the scrape interval to every 15 seconds. Default is every 1 minute. - evaluation_interval: 15s # Evaluate rules every 15 seconds. Default is every 1 minute. + scrape_interval: 15s + evaluation_interval: 15s scrape_configs: - job_name: 'notification-management' static_configs: - - targets: ['host.docker.internal:8080'] # Assuming your Go app runs on 8080 and exposes /metrics + - targets: ['app:8080'] + metrics_path: /api/metrics # Queries -# sum(notification_management_requests_total) by (url) \ No newline at end of file +# sum(notification_management_requests_total) by (url) +# +# More queries for Grafana dashboard: + +# HTTP Request Performance Reports +# Request Volume/Rate: +# sum(notification_management_requests_total) by (url, method) +# rate(notification_management_requests_total[5m]) by (url, method) + +# Error Rates: +# sum(rate(notification_management_requests_total{code=~"5.."}[5m])) by (url) +# sum(rate(notification_management_requests_total{code=~"4.."}[5m])) by (url) +# sum(notification_management_requests_total{code=~"5.."}) by (url) + +# Request Latency/Duration: +# histogram_quantile(0.99, sum(rate(notification_management_request_duration_seconds_bucket[5m])) by (le, url, method)) +# rate(notification_management_request_duration_seconds_sum[5m]) / rate(notification_management_request_duration_seconds_count[5m]) + +# Request and Response Sizes: +# histogram_quantile(0.99, sum(rate(notification_management_request_size_bytes_bucket[5m])) by (le, url, method)) +# histogram_quantile(0.99, sum(rate(notification_management_response_size_bytes_bucket[5m])) by (le, url, method)) + +# Go Application Runtime Reports +# Goroutine Count: +# go_goroutines + +# Memory Usage: +# go_memstats_alloc_bytes +# go_memstats_sys_bytes +# rate(go_memstats_alloc_bytes_total[5m]) + +# Garbage Collection (GC) Performance: +# go_gc_duration_seconds{quantile="0.99"} +# rate(go_gc_duration_seconds_count[5m]) + +# Process-Level System Reports +# CPU Usage: +# rate(process_cpu_seconds_total[5m]) + +# Resident Memory: +# process_resident_memory_bytes + +# File Descriptors: +# process_open_fds + +# Network I/O: +# rate(process_network_receive_bytes_total[5m]) +# rate(process_network_transmit_bytes_total[5m]) diff --git a/repositories/sql_common.go b/repositories/sql_common.go index 286eacd..33e9740 100644 --- a/repositories/sql_common.go +++ b/repositories/sql_common.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "github.com/jackc/pgx/v5/pgconn" "gorm.io/gorm" ) @@ -13,26 +14,64 @@ import ( type SQLRepository[T any] struct { db *gorm.DB } +type txContextKey string + +const TXContextKey txContextKey = "txContext" + +type TxContextKey struct { + DB *gorm.DB + Filter []*Filter +} type Filter struct { - Field string - Op string // e.g., "=", "LIKE", "IN" - Value interface{} + Field string + Op string + Value interface{} + Applied bool } -type ContextStruct struct { - Filter *[]Filter +func NewFilter(field string, op string, value interface{}) *Filter { + return &Filter{Value: value, Op: op, Field: field, Applied: false} } func NewSQLRepository[T any](db *gorm.DB) domain.Repository[T, uint] { return &SQLRepository[T]{db: db} } + func (r *SQLRepository[T]) GetDB(ctx context.Context) *gorm.DB { - return r.db + if tx, ok := GetTxContext(ctx); ok { + return tx.DB.WithContext(ctx) + } + return r.db.WithContext(ctx) +} + +func GetTxContext(ctx context.Context) (*TxContextKey, bool) { + if contextKey, ok := ctx.Value(TXContextKey).(*TxContextKey); ok { + return contextKey, true + } + return nil, false +} + +func ApplyFilter(ctx context.Context, query *gorm.DB) *gorm.DB { + if contextKey, ok := GetTxContext(ctx); ok { + for i, f := range contextKey.Filter { + if f.Applied { + continue + } + clause := fmt.Sprintf("%s %s ?", f.Field, f.Op) + query = query.Where(clause, f.Value) + (contextKey.Filter)[i].Applied = true + } + } + return query +} + +func (r *SQLRepository[T]) WithTx(tx *gorm.DB) domain.Repository[T, uint] { + return &SQLRepository[T]{db: tx} } func (r *SQLRepository[T]) Create(ctx context.Context, entity *T) error { - withContext := r.db.WithContext(ctx) + withContext := r.GetDB(ctx) withContext = ApplyFilter(ctx, withContext) err := withContext.Create(entity).Error if err != nil { @@ -60,7 +99,7 @@ func handleDbError(err error) error { func (r *SQLRepository[T]) GetByID(ctx context.Context, id uint, preloads *[]string) (*T, error) { var entity T - dbContext := r.db.WithContext(ctx) + dbContext := r.GetDB(ctx) if preloads != nil { for _, it := range *preloads { dbContext = dbContext.Preload(it) @@ -76,7 +115,7 @@ func (r *SQLRepository[T]) GetByID(ctx context.Context, id uint, preloads *[]str func (r *SQLRepository[T]) GetAll(ctx context.Context, limit, offset int) ([]T, error) { var entities []T - withContext := r.db.WithContext(ctx) + withContext := r.GetDB(ctx) withContext = ApplyFilter(ctx, withContext) err := withContext.Limit(limit).Offset(offset).Find(&entities).Error if err != nil { @@ -85,21 +124,8 @@ func (r *SQLRepository[T]) GetAll(ctx context.Context, limit, offset int) ([]T, return entities, nil } -func ApplyFilter(ctx context.Context, query *gorm.DB) *gorm.DB { - key := ContextStruct{} - - type ExtraFilters *[]Filter - if contextKey, ok := ctx.Value(key).(*ContextStruct); ok { - for _, f := range *contextKey.Filter { - clause := fmt.Sprintf("%s %s ?", f.Field, f.Op) - query = query.Where(clause, f.Value) - } - } - return query -} - func (r *SQLRepository[T]) Update(ctx context.Context, entity *T) error { - err := r.db.WithContext(ctx).Save(entity).Error + err := r.GetDB(ctx).Save(entity).Error if err != nil { return handleDbError(err) } @@ -108,7 +134,7 @@ func (r *SQLRepository[T]) Update(ctx context.Context, entity *T) error { func (r *SQLRepository[T]) Delete(ctx context.Context, id uint) error { var entity T - res := r.db.WithContext(ctx).Delete(&entity, id) + res := r.GetDB(ctx).Delete(&entity, id) err := res.Error if err != nil { return errutil.NewAppError(errutil.ErrDatabaseQuery, err) @@ -122,7 +148,7 @@ func (r *SQLRepository[T]) Delete(ctx context.Context, id uint) error { func (r *SQLRepository[T]) Count(ctx context.Context) (int64, error) { var count int64 - err := r.db.Model(new(T)).Count(&count).Error + err := r.GetDB(ctx).Model(new(T)).Count(&count).Error if err != nil { return 0, handleDbError(err) } @@ -131,13 +157,13 @@ func (r *SQLRepository[T]) Count(ctx context.Context) (int64, error) { func (r *SQLRepository[T]) GetByIDs(ctx context.Context, ids []uint, preloads *[]string) ([]T, error) { var entities []T - dbContext := r.db.WithContext(ctx) + dbContext := r.GetDB(ctx) if preloads != nil { for _, it := range *preloads { dbContext = dbContext.Preload(it) } } - dbContext = ApplyFilter(ctx, r.db) + dbContext = ApplyFilter(ctx, dbContext) err := dbContext.Where("id IN (?)", ids).Find(&entities).Error if err != nil { return nil, handleDbError(err) diff --git a/repositories/user.go b/repositories/user.go index 7efa830..5a49f49 100644 --- a/repositories/user.go +++ b/repositories/user.go @@ -10,28 +10,18 @@ import ( type UserRepositoryImpl struct { domain.Repository[models.User, uint] - db *gorm.DB } func NewUserRepository(db *gorm.DB) domain.UserRepository { return &UserRepositoryImpl{ Repository: NewSQLRepository[models.User](db), - db: db, } } func (r *UserRepositoryImpl) FindByKeycloakID(keycloakID string, ctx context.Context) (*models.User, error) { var user models.User - err := r.db.Where("keycloak_id = ?", keycloakID).First(&user).Error - if err != nil { - return nil, handleDbError(err) - } - return &user, nil -} -func (r *UserRepositoryImpl) FindByEmail(email string) (*models.User, error) { - var user models.User - err := r.db.Where("email = ?", email).First(&user).Error + err := r.GetDB(ctx).Where("keycloak_id = ?", keycloakID).First(&user).Error if err != nil { return nil, handleDbError(err) } diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh index 0bf5a9e..6263f28 100755 --- a/scripts/setup-dev.sh +++ b/scripts/setup-dev.sh @@ -14,7 +14,7 @@ if ! docker info > /dev/null 2>&1; then fi echo "📦 Starting LocalStack..." -docker compose up -d config-server postgres keycloak mailcatcher redis +docker compose up -d config-server postgres keycloak_svc mailcatcher redis echo "⏳ Waiting for config-server to be ready..." sleep 10 diff --git a/server/server.go b/server/server.go index e8f771f..3fdec1d 100644 --- a/server/server.go +++ b/server/server.go @@ -10,15 +10,15 @@ import ( "NotificationManagement/routes" "NotificationManagement/services" "NotificationManagement/services/notifier" - "NotificationManagement/utils/errutil" "github.com/labstack/echo/v4" "go.uber.org/fx" + "gorm.io/gorm" ) -func NewEcho() *echo.Echo { +func NewEcho(db *gorm.DB) *echo.Echo { e := echo.New() e.Use(interceptLogger) - e.Use(errutil.ErrorHandler()) + e.Use(middleware.TransactionMiddleware(db)) return e } @@ -50,6 +50,7 @@ func interceptLogger(next echo.HandlerFunc) echo.HandlerFunc { var Module = fx.Options( fx.Provide( NewEcho, + conn.NewDB, conn.NewAsynq, conn.NewAsynqInspector, notifier.NewEmailNotifier, diff --git a/services/curl.go b/services/curl.go index e81c24f..f7ac1e6 100644 --- a/services/curl.go +++ b/services/curl.go @@ -30,7 +30,7 @@ func (s *CurlServiceImpl) GetModelById(c context.Context, id uint, preloads *[]s if preloads == nil { preloads = &[]string{"AdditionalFields"} } - return s.CurlRepo.GetByID(s.GetInstance().GetContext(), id, preloads) + return s.CurlRepo.GetByID(s.GetInstance().ProcessContext(c), id, preloads) } func NewCurlService(repo domain.CurlRequestRepository, fieldsRepository domain.AdditionalFieldsRepository) domain.CurlService { @@ -100,7 +100,7 @@ func parseBasicCurl(raw string) (method, url string, headers map[string]string, } func executeCurlCommand(command string) (string, error) { - cmd := exec.Command("bash", "-c", command) + cmd := exec.Command("sh", "-c", command) output, err := cmd.CombinedOutput() if err != nil { return "", errutil.NewAppErrorWithMessage(errutil.ErrCurlCommandExecutionFailed, err, fmt.Sprintf("Output: %s", output)) @@ -218,12 +218,11 @@ func (s *CurlServiceImpl) UpdateModel(c context.Context, id uint, model *models. existingIDsMap := make(map[uint]bool) if len(idsToCheck) > 0 { - background := context.Background() - f := []repositories.Filter{ - {Field: "request_id", Op: "=", Value: id}, + if txContext, ok := repositories.GetTxContext(c); ok { + filters := append(txContext.Filter, repositories.NewFilter("request_id", "=", id)) + txContext.Filter = filters } - background = context.WithValue(background, repositories.ContextStruct{}, &repositories.ContextStruct{Filter: &f}) - existingAdditionalFields, err := s.AdditionalFieldRepo.GetByIDs(background, idsToCheck, nil) + existingAdditionalFields, err := s.AdditionalFieldRepo.GetByIDs(c, idsToCheck, nil) if err != nil { return nil, err } diff --git a/services/deepseek.go b/services/deepseek.go index d9baf60..2ba7c47 100644 --- a/services/deepseek.go +++ b/services/deepseek.go @@ -30,12 +30,13 @@ func NewDeepseekModelService(repo domain.DeepseekModelRepository, curl domain.Cu return service } -func (s *DeepseekServiceImpl) GetContext() context.Context { - background := context.Background() - f := []repositories.Filter{ - {Field: "type", Op: "=", Value: "deepseek"}, +func (s *DeepseekServiceImpl) ProcessContext(ctx context.Context) context.Context { + if txContext, ok := repositories.GetTxContext(ctx); ok { + filters := append(txContext.Filter, repositories.NewFilter("type", "=", s.GetModelType())) + txContext.Filter = filters } - return context.WithValue(background, repositories.ContextStruct{}, &repositories.ContextStruct{Filter: &f}) + + return ctx } func (s *DeepseekServiceImpl) MakeAIRequest(c context.Context, m *models.AIModel, requestId uint) (interface{}, error) { diff --git a/services/gemini.go b/services/gemini.go index 48db8a3..bc31774 100644 --- a/services/gemini.go +++ b/services/gemini.go @@ -27,12 +27,12 @@ func NewGeminiService(repo domain.GeminiModelRepository, curlService domain.Curl return service } -func (s *GeminiServiceImpl) GetContext() context.Context { - background := context.Background() - f := []repositories.Filter{ - {Field: "type", Op: "=", Value: "gemini"}, +func (s *GeminiServiceImpl) ProcessContext(ctx context.Context) context.Context { + if txContext, ok := repositories.GetTxContext(ctx); ok { + filters := append(txContext.Filter, repositories.NewFilter("type", "=", s.GetModelType())) + txContext.Filter = filters } - return context.WithValue(background, repositories.ContextStruct{}, &repositories.ContextStruct{Filter: &f}) + return ctx } func (s *GeminiServiceImpl) MakeAIRequest(c context.Context, m *models.AIModel, requestId uint) (interface{}, error) { @@ -49,7 +49,7 @@ func (s *GeminiServiceImpl) MakeAIRequest(c context.Context, m *models.AIModel, if err != nil { return nil, err } - respBody, err := geminiCall(model, curlResponse, curl) + respBody, err := geminiCall(c, model, curlResponse, curl) if err != nil { return nil, errutil.NewAppError(errutil.ErrExternalServiceError, err) } @@ -73,13 +73,11 @@ func (s *GeminiServiceImpl) GetModelType() string { return "gemini" } -func geminiCall(model *models.GeminiModel, response *types.CurlResponse, req *models.CurlRequest) (*genai.GenerateContentResponse, error) { +func geminiCall(ctx context.Context, model *models.GeminiModel, response *types.CurlResponse, req *models.CurlRequest) (*genai.GenerateContentResponse, error) { assistantContent, err := response.GetAssistantContent(req.ResponseType) if err != nil { return nil, err } - ctx := context.Background() - // The client gets the API key from the environment variable `GEMINI_API_KEY`. client, err := genai.NewClient(ctx, &genai.ClientConfig{ APIKey: model.GetAPIKey(), }) diff --git a/services/notifier/dispatcher.go b/services/notifier/dispatcher.go index 1fdb4c9..3124e94 100644 --- a/services/notifier/dispatcher.go +++ b/services/notifier/dispatcher.go @@ -19,9 +19,6 @@ func NewNotificationDispatcher(email *EmailNotifier, sms *SMSNotifier, telegram } func (d *Dispatcher) Notify(ctx context.Context, notification *types.Notification) error { - if ctx != nil { - ctx = context.Background() - } if notification.User == nil { user, err := d.UserService.GetModelById(ctx, notification.UserId, &[]string{"Telegram"}) if err != nil { diff --git a/services/service.go b/services/service.go index 9f80673..d2f7298 100644 --- a/services/service.go +++ b/services/service.go @@ -8,27 +8,27 @@ import ( ) type CommonServiceImpl[T any] struct { - Repo domain.Repository[T, uint] + domain.Repository[T, uint] Instance domain.CommonService[T] } func NewCommonService[T any](repo domain.Repository[T, uint], instance domain.CommonService[T]) domain.CommonService[T] { - return &CommonServiceImpl[T]{Repo: repo, Instance: instance} + return &CommonServiceImpl[T]{Repository: repo, Instance: instance} } -func (s *CommonServiceImpl[T]) GetContext() context.Context { - return context.Background() +func (s *CommonServiceImpl[T]) ProcessContext(c context.Context) context.Context { + return c } func (s *CommonServiceImpl[T]) GetInstance() domain.CommonService[T] { return s.Instance } func (s *CommonServiceImpl[T]) CreateModel(c context.Context, entity *T) error { - return s.Repo.Create(s.Instance.GetContext(), entity) + return s.Create(s.Instance.ProcessContext(c), entity) } func (s *CommonServiceImpl[T]) GetModelById(c context.Context, id uint, preloads *[]string) (*T, error) { - model, err := s.Repo.GetByID(s.Instance.GetContext(), id, preloads) + model, err := s.GetByID(s.Instance.ProcessContext(c), id, preloads) if err != nil { return nil, err } @@ -36,7 +36,7 @@ func (s *CommonServiceImpl[T]) GetModelById(c context.Context, id uint, preloads } func (s *CommonServiceImpl[T]) GetAllModels(c context.Context, limit, offset int) ([]T, error) { - m, err := s.Repo.GetAll(s.Instance.GetContext(), limit, offset) + m, err := s.GetAll(s.Instance.ProcessContext(c), limit, offset) if err != nil { return nil, err } @@ -44,13 +44,12 @@ func (s *CommonServiceImpl[T]) GetAllModels(c context.Context, limit, offset int } func (s *CommonServiceImpl[T]) UpdateModel(c context.Context, id uint, model *T) (*T, error) { - // Check if the model implements ModelInterface modelUpdater, ok := any(model).(models.ModelInterface) if !ok { return nil, errutil.NewAppError(errutil.ErrFeatureNotAvailable, errutil.ErrInvalidFeature) } - existing, err := s.Repo.GetByID(s.Instance.GetContext(), id, nil) + existing, err := s.GetByID(s.Instance.ProcessContext(c), id, nil) if err != nil { return nil, err } @@ -58,10 +57,10 @@ func (s *CommonServiceImpl[T]) UpdateModel(c context.Context, id uint, model *T) existingUpdater.UpdateFromModel(modelUpdater) } - err = s.Repo.Update(s.Instance.GetContext(), existing) + err = s.Update(s.Instance.ProcessContext(c), existing) return existing, err } func (s *CommonServiceImpl[T]) DeleteModel(c context.Context, id uint) error { - return s.Repo.Delete(s.Instance.GetContext(), id) + return s.Delete(s.Instance.ProcessContext(c), id) } diff --git a/services/user.go b/services/user.go index b623331..34e5686 100644 --- a/services/user.go +++ b/services/user.go @@ -21,8 +21,7 @@ func NewUserService(repo domain.UserRepository) domain.UserService { return service } -func (s *UserServiceImpl) RegisterOrUpdateUser(user *models.User) (*models.User, error) { - ctx := context.Background() +func (s *UserServiceImpl) RegisterOrUpdateUser(ctx context.Context, user *models.User) (*models.User, error) { existingUser, err := s.UserRepo.FindByKeycloakID(user.KeycloakID, ctx) if err != nil { var appErr *errutil.AppError @@ -47,7 +46,3 @@ func (s *UserServiceImpl) RegisterOrUpdateUser(user *models.User) (*models.User, } return existingUser, nil } - -func (s *UserServiceImpl) GetContext() context.Context { - return context.Background() -} diff --git a/utils/errutil/error_handler.go b/utils/errutil/error_handler.go index 2eb29a1..e1d7a66 100644 --- a/utils/errutil/error_handler.go +++ b/utils/errutil/error_handler.go @@ -15,12 +15,12 @@ func ErrorHandler() echo.MiddlewareFunc { if err == nil { return nil } - return handleError(c, err) + return HandleError(c, err) } } } -func handleError(c echo.Context, err error) error { +func HandleError(c echo.Context, err error) error { var appError *AppError if errors.As(err, &appError) {