diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..07f7a04 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..8133642 --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,77 @@ +name: Team1 CI - Build and Push Docker Image + +on: + push: + branches: ## TODO: dev or main + - dev + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + service: [mindscape-auth, mindscape-info, mindscape-service] + + steps: + - uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + java-version: '17' + distribution: 'temurin' + cache: maven + +# TEST: mindscape-info + - name: Build with Maven + run: mvn -B -DskipTests package --file pom.xml + working-directory: ./${{ matrix.service }} + +## JAR + - name: Rename jar file + run: mv ./target/*.jar ./target/app.jar + working-directory: ./${{ matrix.service }} + + - name: Check jar file + run: ls ./target + working-directory: ./${{ matrix.service }} + + - uses: actions/upload-artifact@v4 + with: + name: app-${{ matrix.service }} + path: ./${{ matrix.service }}/target/*.jar +## AWS SETTINGS + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ap-northeast-2 + + - name: Set SHORT_SHA environment variable + run: echo "SHORT_SHA=${GITHUB_SHA::7}" >> $GITHUB_ENV +## DOCKER + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + install: true + driver: docker-container + + - name: Login to Amazon ECR + uses: aws-actions/amazon-ecr-login@v2 + + - name: Build and push Docker image to ECR + uses: docker/build-push-action@v6 + with: + context: ./${{ matrix.service }} + file: ./${{ matrix.service }}/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + tags: | + ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/team1-${{ matrix.service }}:${{ env.SHORT_SHA }} + ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.ap-northeast-2.amazonaws.com/team1-${{ matrix.service }}:latest + + + + diff --git a/.github/workflows/update-secret.yaml b/.github/workflows/update-secret.yaml new file mode 100644 index 0000000..a5fd524 --- /dev/null +++ b/.github/workflows/update-secret.yaml @@ -0,0 +1,45 @@ +# name: Inject RDS/Redis into SealedSecret + +# on: +# push: +# branches: +# - dev-terraform +# workflow_dispatch: + +# jobs: +# inject-secrets: +# runs-on: ubuntu-latest +# steps: +# - name: Checkout repo +# uses: actions/checkout@v3 + +# - name: Configure AWS credentials +# uses: aws-actions/configure-aws-credentials@v2 +# with: +# role-to-assume: arn:aws:iam::194722398200:role/Team1-github-role +# aws-region: ap-northeast-2 + +# - name: Get Terraform outputs +# run: | +# cd terraform/ +# DB_HOST=$(terraform output -raw rds_endpoint) +# REDIS_HOST=$(terraform output -raw redis_endpoint) +# echo "DB_HOST=$DB_HOST" >> $GITHUB_ENV +# echo "REDIS_HOST=$REDIS_HOST" >> $GITHUB_ENV + +# - name: Generate Sealed Secret +# run: | +# DB_URL="jdbc:mysql://${{ env.DB_HOST }}:3306/mindscape?serverTimezone=Asia/Seoul" +# sed -e "s|REPLACE_DB_URL|$DB_URL|" \ +# -e "s|REPLACE_REDIS_HOST|$REDIS_HOST|" \ +# sealed-secret-add.yaml > sealed-secret.yaml + +# kubeseal \ +# --controller-name=sealed-secrets-controller \ +# --controller-namespace=kube-system \ +# --format=yaml \ +# < sealed-secret.yaml > sealed-secret-final.yaml + +# - name: Apply Sealed Secret to cluster +# run: | +# kubectl apply -f sealed-secret-final.yaml diff --git a/.gitignore b/.gitignore index 0cb43d3..2847dde 100644 --- a/.gitignore +++ b/.gitignore @@ -224,5 +224,49 @@ buildNumber.properties .project # JDT-specific (Eclipse Java Development Tools) .classpath + +# End of https://www.toptal.com/developers/gitignore/api/eclipse,intellij,maven,java + + +# Local .terraform directories +.terraform/ + +# .tfstate files +*.tfstate +*.tfstate.* + +# Crash log files +crash.log +crash.*.log + +# Exclude all .tfvars files, which are likely to contain sensitive data, such as +# password, private keys, and other secrets. These should not be part of version +# control as they are data points which are potentially sensitive and subject +# to change depending on the environment. +*.tfvars +*.tfvars.json + +# Ignore override files as they are usually used to override resources locally and so +# are not checked in +override.tf +override.tf.json +*_override.tf +*_override.tf.json + +# Ignore transient lock info files created by terraform apply +.terraform.tfstate.lock.info + +# Include override files you do wish to add to version control using negated pattern +# !example_override.tf + +# Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan +# example: *tfplan* + +# Ignore CLI configuration files +.terraformrc +terraform.rc + +terraform.tfvars + *.properties # End of https://www.toptal.com/developers/gitignore/api/eclipse,intellij,maven,java diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..3b60efd --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,14 @@ +<<<<<<< HEAD +# Default ignored files +/shelf/ +/workspace.xml +======= +# 디폴트 무시된 파일 +/shelf/ +/workspace.xml +# 에디터 기반 HTTP 클라이언트 요청 +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml +>>>>>>> user-test diff --git a/.idea/Final-Team1-Backend-dev 2.iml b/.idea/Final-Team1-Backend-dev 2.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/Final-Team1-Backend-dev 2.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/Final-Team1-Backend-dev.iml b/.idea/Final-Team1-Backend-dev.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/Final-Team1-Backend-dev.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/Final-Team1-Backend-main.iml b/.idea/Final-Team1-Backend-main.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/Final-Team1-Backend-main.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/Final-Team1-Backend.iml b/.idea/Final-Team1-Backend.iml new file mode 100644 index 0000000..d6ebd48 --- /dev/null +++ b/.idea/Final-Team1-Backend.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 0000000..8993d89 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +<<<<<<< HEAD +======= + + + + + + + + +>>>>>>> e65f3bd ([feat] feat/search) + + + + + +<<<<<<< HEAD +======= + +>>>>>>> e65f3bd ([feat] feat/search) + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 0000000..d3c6eeb --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,15 @@ + + + + +<<<<<<< HEAD + + +======= + + + +>>>>>>> e65f3bd ([feat] feat/search) + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 0000000..712ab9d --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..f41bea0 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..6231f27 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Final-Team1-Backend b/Final-Team1-Backend new file mode 160000 index 0000000..1341661 --- /dev/null +++ b/Final-Team1-Backend @@ -0,0 +1 @@ +Subproject commit 134166116e6dc35fa93589abf8a98dffc72469b9 diff --git a/argocd/manifests/alert/alert-discord.yaml b/argocd/manifests/alert/alert-discord.yaml new file mode 100644 index 0000000..2883251 --- /dev/null +++ b/argocd/manifests/alert/alert-discord.yaml @@ -0,0 +1,42 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + annotations: + argocd.argoproj.io/sync-wave: "1" + name: alertmanager-discord + namespace: prometheus + labels: { app: alertmanager-discord } +spec: + replicas: 1 + selector: + matchLabels: { app: alertmanager-discord } + template: + metadata: + labels: { app: alertmanager-discord } + spec: + containers: + - name: alertmanager-discord + image: benjojo/alertmanager-discord:latest + ports: + - containerPort: 9094 + env: + - name: DISCORD_WEBHOOK + valueFrom: + secretKeyRef: + name: alertmanager-discord-secret # ← SealedSecret가 생성해줄 평문 Secret 이름과 일치 + key: DISCORD_WEBHOOK + - name: DISCORD_USERNAME + value: "Prometheus Bot" +--- +apiVersion: v1 +kind: Service +metadata: + name: alertmanager-discord + namespace: prometheus + labels: { app: alertmanager-discord } +spec: + selector: { app: alertmanager-discord } + ports: + - name: http + port: 9094 + targetPort: 9094 diff --git a/argocd/manifests/alert/alertmanager-discord-sealed.yaml b/argocd/manifests/alert/alertmanager-discord-sealed.yaml new file mode 100644 index 0000000..f5e941d --- /dev/null +++ b/argocd/manifests/alert/alertmanager-discord-sealed.yaml @@ -0,0 +1,15 @@ + +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: alertmanager-discord-secret + namespace: prometheus +spec: + encryptedData: + DISCORD_WEBHOOK: AgA/gEY4QOcc5IEQJ0hhSEPVP/kHhc2g5bUTQFAwbFw4VpzYq5WjXrm7jYtswPF0j2V5qZcJgbcLafxst5ryw0a1r7ZUDm5X2BNreYYAzhANAXyYv+8kIZtENRKjiKVBvqxR6XhdE8cijAIXX1ipUKVmTfVFWhaQxhY07U/NACM2JYqqosKe8EwZmsreWe30EN4XVko/duuV2iuSbmZrIfcWWafGUGDJHc4T/76I4CucAGzD/X4Pp27Jp1mWumi8tmawHeemPfHikbfcAANaLkL60KGhWOLEIN8kkFIM+m+1hMRgM4Jxtmk/LsXubXpgYkHcMh6CY1WgcdIxRFWmBtAi/ZShRSvKMaJPPGGVYTR0K5Cp1/QdNo3Cx7fZRkqDbu5YH0VatOCD8NmXX5GvVSERPeI3nMSDnKyRREhze7yShSNMk+Rj8EkdCx85GlBZ8XXM9t0NVx13GYr9hyKOyFyj+I7Lp7RWnCkUPoTewojt9kcVfUodQ+ijeb5PPwMtX5cp13jiFOARxa39mtdVDkgilR/c2EFhkGZ/00DEEzupLanMhyt1AYqSIjFEK2WOgN3EtXs9lhaZ50/cMLdOh4/PWYFLxsS3NSbTXxHszEV6Sox5O8Z1sjI1q2UZb98kkSbLYFm4+7z/L7Y33tvE2HqmvAnEEdNiBbxzFHoGOdZ6pWxQZn/r8tRqIqEXEAduGB9TMV5HP217pOB/mbh4hx5wDFYCCKsTdndUGpHLAJZK0PT8vzftHriyAt7t/WasF0sCcmVhJPwl3A2zmx2h+GGMqFQ/X+/S/+aDbO0YDDmZY0rCy7oBtnHH0xxLQolXLrMIMaTGIebmdKrwBIQ9nRZBr/Cz9wQgfzknoIS+ + template: + metadata: + creationTimestamp: null + name: alertmanager-discord-secret + namespace: prometheus diff --git a/argocd/manifests/alert/alertmanager-secret.yaml b/argocd/manifests/alert/alertmanager-secret.yaml new file mode 100644 index 0000000..a05baf0 --- /dev/null +++ b/argocd/manifests/alert/alertmanager-secret.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: Secret +metadata: + name: alertmanager-prometheus-kube-prometheus-alertmanager + namespace: prometheus +type: Opaque +stringData: + alertmanager.yaml: | + global: + resolve_timeout: 5m + http_config: + follow_redirects: true + enable_http2: true + + route: + receiver: discord + group_by: [alertname] + group_wait: 3s # 최초 묶음 대기 짧게 + group_interval: 20s # 같은 그룹의 추가 알림 최소 간격 + repeat_interval: 1m # 같은 건이 계속 firing이면 재전송 간격 + routes: + - matchers: ['alertname="Watchdog"'] + receiver: "null" + - matchers: ['alertname="InfoInhibitor"'] + receiver: "null" + - matchers: ['severity="info"'] + receiver: "null" + + + receivers: + - name: discord + webhook_configs: + - url: http://alertmanager-discord.prometheus.svc.cluster.local:9094/ + send_resolved: true + - name: "null" + diff --git a/argocd/manifests/alert/kustomization.yaml b/argocd/manifests/alert/kustomization.yaml new file mode 100644 index 0000000..8480b90 --- /dev/null +++ b/argocd/manifests/alert/kustomization.yaml @@ -0,0 +1,9 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +namespace: prometheus + +resources: + - alertmanager-discord-sealed.yaml # 방금 만든 SealedSecret + - alert-discord.yaml # 디스코드 브릿지(Deployment+Service) + - alertmanager-secret.yaml # Alertmanager 라우팅 설정 + - prometheusRules.yaml # CPU 알림 룰 diff --git a/argocd/manifests/alert/prometheusRules.yaml b/argocd/manifests/alert/prometheusRules.yaml new file mode 100644 index 0000000..73dbf63 --- /dev/null +++ b/argocd/manifests/alert/prometheusRules.yaml @@ -0,0 +1,35 @@ +apiVersion: monitoring.coreos.com/v1 +kind: PrometheusRule +metadata: + name: pod-high-cpu + namespace: prometheus + labels: + release: prometheus # ← 필수! (Prometheus CR의 ruleSelector에 맞춤) +spec: + groups: + - name: pod-cpu.rules + interval: 5s + rules: + - alert: PodHighCPU + expr: | + ( + 100 * + sum by (namespace, pod) ( + rate(container_cpu_usage_seconds_total{container!="POD"}[1m]) + ) / + clamp_min( + sum by (namespace, pod) ( + kube_pod_container_resource_requests{resource="cpu"} + ), + 0.001 + ) + ) > 70 + labels: + severity: warning + annotations: + summary: "🔥 CPU 과부하: {{ $labels.namespace }}/{{ $labels.pod }}" + description: | + • 사용률: {{ printf "%.1f" $value }}% + • 임계치: > 70% + • namespace: {{ $labels.namespace }} + • pod: {{ $labels.pod }} diff --git a/argocd/manifests/ingress/alb-controller/application.yaml b/argocd/manifests/ingress/alb-controller/application.yaml new file mode 100644 index 0000000..64e9692 --- /dev/null +++ b/argocd/manifests/ingress/alb-controller/application.yaml @@ -0,0 +1,28 @@ +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: alb-controller + namespace: argocd +spec: + project: default + source: + repoURL: https://aws.github.io/eks-charts + chart: aws-load-balancer-controller + targetRevision: 1.6.2 + helm: + values: | + clusterName: Team1-backend-eks-cluster + region: ap-northeast-2 + vpcId: vpc-0c9b08096e32bf821 # 계속 변경 + serviceAccount: + create: true + name: aws-load-balancer-controller + annotations: + eks.amazonaws.com/role-arn: arn:aws:iam::194722398200:role/Team1-backend-alb-irsa-role # 확인해야함 + destination: + server: https://kubernetes.default.svc + namespace: kube-system + syncPolicy: + automated: + prune: true + selfHeal: true diff --git a/argocd/manifests/ingress/app/ingress.yaml b/argocd/manifests/ingress/app/ingress.yaml new file mode 100644 index 0000000..df90e5a --- /dev/null +++ b/argocd/manifests/ingress/app/ingress.yaml @@ -0,0 +1,72 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: app-ingress + namespace: app + annotations: + kubernetes.io/ingress.class: alb + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS":443}]' + alb.ingress.kubernetes.io/group.name: app + alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:ap-northeast-2:194722398200:certificate/6dbefdb5-afea-4d21-9928-8536cb706e50 + +spec: + rules: + - http: + paths: + - path: /api/content + pathType: Prefix + backend: + service: + name: mindscape-service + port: + number: 80 + + - path: /api/response + pathType: Prefix + backend: + service: + name: mindscape-service + port: + number: 80 + + - path: /api/gemini/recommend + pathType: Prefix + backend: + service: + name: mindscape-service + port: + number: 80 + + - path: /auth + pathType: Prefix + backend: + service: + name: mindscape-auth + port: + number: 80 + + - path: /login + pathType: Prefix + backend: + service: + name: mindscape-auth + port: + number: 80 + + - path: /oauth2 + pathType: Prefix + backend: + service: + name: mindscape-auth + port: + number: 80 + + - path: /api/test + pathType: Prefix + backend: + service: + name: mindscape-info + port: + number: 80 diff --git a/argocd/manifests/ingress/grafana/ingress.yaml b/argocd/manifests/ingress/grafana/ingress.yaml new file mode 100644 index 0000000..692743a --- /dev/null +++ b/argocd/manifests/ingress/grafana/ingress.yaml @@ -0,0 +1,23 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: grafana-ingress + namespace: grafana + annotations: + kubernetes.io/ingress.class: alb + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}]' + alb.ingress.kubernetes.io/group.name: monitoring +spec: + ingressClassName: alb + rules: + - http: + paths: + - path: /grafana + pathType: Prefix + backend: + service: + name: grafana + port: + number: 80 diff --git a/argocd/manifests/ingress/prometheus/ingress.yaml b/argocd/manifests/ingress/prometheus/ingress.yaml new file mode 100644 index 0000000..91d55bd --- /dev/null +++ b/argocd/manifests/ingress/prometheus/ingress.yaml @@ -0,0 +1,43 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: prometheus-ingress + namespace: prometheus + annotations: + kubernetes.io/ingress.class: alb + alb.ingress.kubernetes.io/scheme: internet-facing + alb.ingress.kubernetes.io/target-type: ip + alb.ingress.kubernetes.io/listen-ports: '[{"HTTP":80}]' + alb.ingress.kubernetes.io/group.name: monitoring + alb.ingress.kubernetes.io/target-group-port: "9090" + alb.ingress.kubernetes.io/healthcheck-path: /prometheus/-/healthy + alb.ingress.kubernetes.io/healthcheck-port: "9090" + alb.ingress.kubernetes.io/healthcheck-protocol: HTTP +spec: + ingressClassName: alb + rules: + - host: k8s-monitoring-c127b00cfa-1844328069.ap-northeast-2.elb.amazonaws.com # alb dns 주소 + http: + paths: + - path: /prometheus + pathType: Prefix + backend: + service: + name: prometheus-kube-prometheus-prometheus + port: + number: 9090 + - path: / + pathType: Prefix + backend: + service: + name: prometheus-kube-prometheus-alertmanager + port: + number: 9093 + - path: /api/v2 + pathType: Prefix + backend: + service: + name: prometheus-kube-prometheus-alertmanager + port: + number: 9093 + diff --git a/argocd/manifests/msa/auth/app-deployment.yaml b/argocd/manifests/msa/auth/app-deployment.yaml new file mode 100644 index 0000000..afcf8a5 --- /dev/null +++ b/argocd/manifests/msa/auth/app-deployment.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mindscape-auth + namespace: app +spec: + # replicas: 1 + selector: + matchLabels: + app: mindscape-auth + template: + metadata: + labels: + app: mindscape-auth + spec: + containers: + - name: mindscape-auth + image: 194722398200.dkr.ecr.ap-northeast-2.amazonaws.com/team1-mindscape-auth:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: mindscape-secret + resources: + requests: + cpu: 300m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/argocd/manifests/msa/auth/app-service.yaml b/argocd/manifests/msa/auth/app-service.yaml new file mode 100644 index 0000000..ebf44a5 --- /dev/null +++ b/argocd/manifests/msa/auth/app-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mindscape-auth + namespace: app +spec: + type: ClusterIP + selector: + app: mindscape-auth + ports: + - port: 80 + targetPort: 8080 + protocol: TCP \ No newline at end of file diff --git a/argocd/manifests/msa/auth/hpa.yaml b/argocd/manifests/msa/auth/hpa.yaml new file mode 100644 index 0000000..c2d6c5b --- /dev/null +++ b/argocd/manifests/msa/auth/hpa.yaml @@ -0,0 +1,31 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: mindscape-auth +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: mindscape-auth + minReplicas: 1 + maxReplicas: 20 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + behavior: + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 100 + periodSeconds: 15 + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 100 + periodSeconds: 15 diff --git a/argocd/manifests/msa/auth/kustomization.yaml b/argocd/manifests/msa/auth/kustomization.yaml new file mode 100644 index 0000000..e1cdf94 --- /dev/null +++ b/argocd/manifests/msa/auth/kustomization.yaml @@ -0,0 +1,4 @@ +resources: + - app-deployment.yaml + - app-service.yaml + - hpa.yaml \ No newline at end of file diff --git a/argocd/manifests/msa/info/app-deployment.yaml b/argocd/manifests/msa/info/app-deployment.yaml new file mode 100644 index 0000000..d169e6a --- /dev/null +++ b/argocd/manifests/msa/info/app-deployment.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mindscape-info + namespace: app +spec: + # replicas: 1 + selector: + matchLabels: + app: mindscape-info + template: + metadata: + labels: + app: mindscape-info + spec: + containers: + - name: mindscape-info + image: 194722398200.dkr.ecr.ap-northeast-2.amazonaws.com/team1-mindscape-info:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: mindscape-secret + resources: + requests: + cpu: 300m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/argocd/manifests/msa/info/app-service.yaml b/argocd/manifests/msa/info/app-service.yaml new file mode 100644 index 0000000..0de7534 --- /dev/null +++ b/argocd/manifests/msa/info/app-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mindscape-info + namespace: app +spec: + type: ClusterIP + selector: + app: mindscape-info + ports: + - port: 80 + targetPort: 8080 + protocol: TCP \ No newline at end of file diff --git a/argocd/manifests/msa/info/hpa.yaml b/argocd/manifests/msa/info/hpa.yaml new file mode 100644 index 0000000..54e26d3 --- /dev/null +++ b/argocd/manifests/msa/info/hpa.yaml @@ -0,0 +1,32 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: mindscape-info +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: mindscape-info + minReplicas: 1 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + # 앱 배포 초반 spike 방지용 + behavior: + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 100 + periodSeconds: 15 + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 100 + periodSeconds: 15 diff --git a/argocd/manifests/msa/info/kustomization.yaml b/argocd/manifests/msa/info/kustomization.yaml new file mode 100644 index 0000000..e1cdf94 --- /dev/null +++ b/argocd/manifests/msa/info/kustomization.yaml @@ -0,0 +1,4 @@ +resources: + - app-deployment.yaml + - app-service.yaml + - hpa.yaml \ No newline at end of file diff --git a/argocd/manifests/msa/kustomization.yaml b/argocd/manifests/msa/kustomization.yaml new file mode 100644 index 0000000..252e28f --- /dev/null +++ b/argocd/manifests/msa/kustomization.yaml @@ -0,0 +1,5 @@ +resources: + - auth/ + - info/ + - service/ + - secret/ \ No newline at end of file diff --git a/argocd/manifests/msa/secret/kustomization.yaml b/argocd/manifests/msa/secret/kustomization.yaml new file mode 100644 index 0000000..1fea742 --- /dev/null +++ b/argocd/manifests/msa/secret/kustomization.yaml @@ -0,0 +1,2 @@ +resources: + - sealed-secret.yaml diff --git a/argocd/manifests/msa/secret/sealed-secret.yaml b/argocd/manifests/msa/secret/sealed-secret.yaml new file mode 100644 index 0000000..f8467b4 --- /dev/null +++ b/argocd/manifests/msa/secret/sealed-secret.yaml @@ -0,0 +1,51 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: mindscape-secret + namespace: app +spec: + encryptedData: + GEMINI_API_KEY: AgAf8de+9HuXlGAR0ayOwhGghxFb5Z1APs+k0NyCsiqVVdDo/RXf8WWCdcW7HycQcaZiksffBs5pSN0tbXqSqUZoCPrMRj3RfbQAgKLV9/SJlGSEG8skqU9R0FLt0OUNvV9WDP32J7lROEN4jOftHRfX3gAeC2arWMiF1x2XybxOd5sNfTNBqMVRcvpPZky40KPrGdAo9c3qJkF4KuBtUUmCx4sRfaakwesn6LIKZDOD1BZ4IG7TT1nrYe3HPulQkUDVaZ4I6ath5A/4sgLSjW3y9loi5NhzJQQG7gBicPEtBiMulBn757qYwFXV/E/kVaUreOu+x07F1Se3ISCEsGW7PRpAl5KDpCILFkviPC+27L3nZW4fxgDrWjXhBPnxWvrvgPJBjxbE589apu4p9p0WW0PDOLSGZHfdev+UU/7K1ngmOYbwZuqDQEU/XyInW7qbvwBIRiyC0nM3bYAHHy1FZnIg7aLsl1STjG7K7fCRxfRIgbAwN3p8Gl/qDGXrFQ4GbDLPDoSYb8f/mOJw84ka7g5tbaD6k1lG/A+emEdpwQJnojqKgse/If6DaGiVITciyq8xlcmmNLW0srY4VD9MBQwkENVyyF1e3wpcdcS/jwD75hP8+2ufm9Q1H2bknogHBt7tAQv9QfT1Bz+e0+qSHJLlnOhGfnZYmmNE1TTAeUCv9KoDmUXpUdj6ZA0gHG6Cc2L43ZOOnXwnBnouTY36eomXz/GePrh08ohovt8nm6hsWbL1LBg= + GEMINI_MODEL_NAME: AgBpaqFlp6N7Fpbd/zFmlYaoHTtNFAZJ/0mn1G1WfUIyxTf0Ord2a1swFvKzWKH0SkOYe9sIzYMiTdAOqxw2PCHB7CkcwytY5Yk0iPFtwPQvkfOonnVyAlzhXTkJfBfmfYtWIbsUNtjAxPGRWGDAFk9IeNXFHS9PyFMV6nd4ohEto6zywy4IS9qxuCdvcoy9J6Yx5nfL6pqyDW9BIsb+qO+quW0WZ/ckBcdLQAntsYPwnvWoeFCnYKkXu0plObVhEumo/wQGqD7LoTLUsfHqlxxQaHJmDVqDpCMwPfnP2wAcMlwihVCXB7DgnoIxdENnddKwRlesVQFQmrcBXNa/9G14uNAFxOpHEHSyOAOxpaf5qs2IXjPFRweil7zkGs+XWrWnGtPMWtVDg5GVxbAldReV4ode3dnnZOSsk8kJ3WPeRfv+QFRXSVTiYGM0nCI+VNt5PuT1hvpz5ljCXG9tMx334fx/7W53/oszu1iTPJcV5pUgn/4DgPC4fh24Qr0TNrcVec8s+1KeESOfKPMrDJegyTHrJUG0JGuMBfJ8w+tTQiYn9ZyPyip9L7yrHZ6yBrScfsVL7+unRx4YtymdgVnDDP1fWyJ3duGp34Vtx6dgmT7WcO4nINO4TEXhkbCXPn98G2OwBels1f0r8qkOJqrAEC3DtziZVrRMcQaTxWnLguemeYVaOCVKzdBePHaByqUV/ZhxjlKCFEyeFXVm4KRjkwjfOMDeoRaHSkUoGcKxkMtm4A== + GOOGLE_CLIENT_ID: AgA2rAK6PuED7B18R8IrrM4xyWuMBdOgOWqBHVkcAfxtU+J1Kgohlc03mbkI47QS3K1e7Hko5DowdiJHs8KdGwxO5/iEf9prJG03nAR3F0HCiEnKmPUeeeTpjyZoSI6gSs+biEu7h93qPFolEugI0pxBGef9E15jzSqkxq5/4A5IcFBUS6rWZ+hPOqlWOH85baqA4Xz5ma6rM2SjzUjT+0ZyKTVqYFwrPrklJWaDvI2upYhBw7iU/AQyulSiH7SHTji5Wf+ts80WjVprVmI1fBnYTJNG8B4EeJzxku2z//ACnmsCYLCv8tyBsg/0fWpkD1/rxRjDM955bIn29ESdvdU2Rpd6CjL8ZvdgQasl4/hpUr6wIwkM5oIPinnfuQeMYj9lp1HSirOra0JZlEjf9NkMkWarqGSMWZi5xsqQdokA9IADzDmLqsL2i7b63Xamw2obWPoMAp8fR3Aa6ADE9RSeYjgzvz098/qKIueUXvh8JdL9sUHFjLL+hj8GkzroRDQoUp62Lq4p2GRGyotMDpg5aApRTS1WISbpE2ryXZHOLxvjtOdqkfN4+/fyxGBEOoc7/gvotj5ypZM2baVCF1kjaybnOvb3PhPo66YbQUIsbFkX5UrY2Mk73F00d0Mff/1CHf+jkTl5RN0RXpMupDv6MFrRHUnOMV65gyvO774WQI2wqOTnw3/3ass3LOfAW1YRDjQ9WphOpJ9Bz/VRzw7vge2T6+UptV1pQGEuTzajGXIp5xROm++CsumNhx7QCPWgo+ny19VDeIWwwviuZw3WRTLn//9W40I= + GOOGLE_CLIENT_SECRET: AgCQ2VegosZBi0jN4ae3awcUf18UL2GHoU05bVEegka+CzlIIM4B4pLapVpg8IU/LaaiLSwxTXRTXm8Q9LMAwTmIEmrcP9oEEKwx3Zv23d5OyakFgdye0Lej7Lxfo8adNsExrfPEiSMSCZQ4iXyPsrkgQvZGEgvhNI5/4DVKCu97aA6OmhJm1INf77+ZZjyaXf8QqDqfgh8ZSr1/wkPkFImsal79l22r3qCz4G2IDJZYobNY4uWe/UnG/Bjs5lZwAwRpUa/AxkLTYUCYiDh9QBVPQXWG76xgE3zcIudpugld0i+5CCOhDkoyAO4fQPNUfO9hoILIrhCcjbG/m6APYtYSYTG7uIpsp2NrfkjI64GkyAPxba+JY6vkEKExFRpXWl/aLffxgPfZfbxahAUFm3MjLyHp0KkWTetN2s5/feeqmOiyIaiVR2eaLXnrFiJMKD8/Bbf+iPstxrdqsreaqcv/99VknT/M0qc2+6qEhZpkntIYSkk1p67MO2N8actiYlKy/OXOA3QcoBEL+j7ngNG0FgRisRE3/yOTgw8PPooPzSLC7emGY909/0Za4elO1JKX/8ENAfMplNPrVPI7FtDt1W2reKqe1JgtE7uV90m9lJKwNn8M2nsNz3YcIYlFEJNSU4bODmpZzAPrOshG9zObBFNYVxgdOLnuLrtnBTHubhTRlFw+21DDWJd2gHFsUIoF14vadJ5uPHXDm7snspQ5TJ/E3A9djKh2GU/Jdw09hwwXUw== + GOOGLE_REDIRECT_URI: AgAg3nAw0EbsX9xt4wo4y62bE8N0I7uaqVuAOMM3IM1WqEmtjCUJMoGIqtJAEQEnIPmVVDlW7zlvOw2t8Je6RWYqmxdbETfmxeQSTOItSR65vmOjmfWUi9GlKWvujFVcPunUBcriZ30eAKQUQCJmvuLaQOLqinzBvDag9xiViZPgOMDl7+LJCxklIU5VK6i0ER3C5sH4JtVRfSyEHIvEU9ZvmRc8aZs+aJeAKAx3S6/7vyIOnV0I0P3QDlr8OSTR1vVrNdRDa1uV4BL6kzGQqFzCM+XWWkDSDADIwCXskGn+jgrhb/OJ/ZjR82gk1D3yKwpH1N+N6fEgO70wPSXEtMdpFlFd28HWkpyGXG/S2qacDoG7n5yNDkOhi7OZtncq6rf04Yq3oLwYQeILXqlxqopAjymcakL4O4+S2S94ik/v9iEpWzbIMhG2vfG0ndjMABCw4dS71tAxegSZqSYr4W4LqdDab+FxjPWIGRNIuzWHMpnR8RGCXivSAySpy9ZZUtGf1kKo4iFqlq5nxX8tfmOL428bA42Q/UMYsxnUMacporXGMOcNA2hLOuGGQeCzC7IByJwWkPGKFFJJQxw1cMZsFswKcWx97Oqd1ZnWp1I0PykpzdMDp1Y63m5M/ZzntMDZddX6UAraomhCce500/Kvfbp9/RD2VLlXsgDD91EkuLM1Jo5qZO2uUxemU6h5tMLAYkTNG8EAKmrvoxceGN2iehkimnGWKe+fVugXKsCcn8jb1UeY+rgSXBnDMQ== + GOOGLE_SCOPE: AgBcqq0ypsJK6hRDVaQ+5k9T6jLj/5YBdefy+ydmovGfJwjK+QBA9Uep6x2BvYOmuy/sHzCOWXydmQ/VD5/qqv+b5J7hVnOxE1de6MUwg4FsjpZzwt0re7QFAseZRAzuSUt6Jc/0ZnESVkG4NQTe/G0IMmSD0QScFT3zYZ0vh0OfVriUJd2LOUVGyZjey4niJn8RmM6mZOugin7GbykLd7KkCHCj+vhakRt3A/gaxhgAUu7T7byHyj1BuQgJqkmVBIfYUtddu4x7tXt8Gn8dMXfiOUM08tPhZttLu/qUcIptIXeMTe9FRV6TlWgGh8eCCpjbKl5qCWWz+eNX24PUREJQZbD83whA+yW28iGEQImb4OjdtG6ETPBxrVBXs9FhlKbm5Wh1ss4jmgz90WI3P2bcF30ixbiHsExhIvWwFqVatg8IsaDbAbRhrRuUS3fjilMW9oHsU6roiaABSnZlWDqHtkZ8jj8ag2uLpMwUgL5ZkwVhg8rSQblerZGJ0OIQiCLdyO8rAOvf5wJWaC76MENJbNyFEU4Rv9UmDR1+VzWm53KHPYx9+bSfqdglqkQkUd0+3tT9meUIc+StZ2hHv1Drfgr1Jr3E5EkSPdMqY1SIvmXUlVS+WTdKXQKiWjHDFLgqVf24BhL2qjyQ6LWRwxZWHE/TzfvUfQlsVlihmiPnHrjGSH3vnP8VNf7z4OCKAQbvAHsCaVB7h1f/CoLq + INFO_APP_URL: AgBa9Azp897RvdjsYr4Fbqk9V34ItJLf9mWAv+CJ+SGUkOFU7bl5XV/iF0GNc8nQSlL/2gkvXmoVa4XTA2U2r8Hl41u7KbK8+0+3Dy803OOCIi3NydAbdMiINqJ2gg5mEdrQltDkPQLOCOESwoz3gbjKdbRYZaXdtNyGgIbkLtu6tJOL5pV7C+PfsTIDys/EtGGje9hUAAVVAojQGXyd7ubwOlmMlazlE/jIdr4Ct2mWVnfSbHqhJDFazDusNIe5xn/KQGVfQtiMrao1iLYLX3B/vmLw8SMwJeYSPEQog7olaWK5snDIpMOHKwJ8ZMrDs6IaT8WVBMMexiRgRrgGDNUt9nmP+hG9GgqTQ+VhGzSQOdWGsafXGLSjbviUY3cpEADONPoUsPfC4o0TMGi4G0rDXF79wYpj5xdEUiv/tQwPOOqPRZdD7DvLIwYL+zlfGmisdQTBPah7HP2/I573EFXQRX48wJS2yXL2j1lKI6yTa4IifyX1Ju1zvYS6BWz7bbkqsiG+eyR5EeHFCT7GY/KdSW8rYouXQkxdyTlrmsyRoquZXKL2advX8t2/CwRBdeaF4CqK/CDsjchyNT7QtIWUP1kqWv3Q8R4nVh/rtxiDNcKumpx3+9D4A4F4HxwIibQCWgLJC14hbLEnVHcYueNNApic++fKm0Nx3vb+mHfFlL2bTyZ7EYRD/vXfWEJyzV4J45xfwbCh49fl/vyTv20qhZEnPeFd34JA4k7KtKP+wTONz8zZw9w= + JWT_ACCESSTOKEN_EXPIRATIONTIME: AgBbv0C11fxUSU1kxrCo/IMznNok6EL5MoswpcPGMEo6Ei03UPt1AT064qxvoq/qAGUtWi4iGGG5t75Qb0D8Fi3Ej0QTU0Wl4DDaRcR+wJJcCA1yEBcx7hsTrkdGVFTGfAsW7bpa8Mui+6W0E9+raS0RfboExzguHwCT2tdmvdiZmXbfFjECmzPKPmLY6mJ/Y/TdLVyLUQXxymBqYj+W/td8HXIO+5lY2RDtOTcUwB9GNSPFBocAcxRUW/lhiMFRY6F6rgVDqRiWd9tUuTJmsrO08J8v8VGFuJFtMftN0uevRw9C7pQZvZcTVA0GNOAiO1BCTLPvc0ud/8E2TjPvb/GC1lTyj1ijP/Jo36Ft7w38D48b+gr1PzYSYRGs3vphjFGG6cBdLxFmQmdsVPUn2I10gzq0jz+p3ZURCMkzBrZ61MI2QDTKEkvI9fS1BuDA9MO7LXyvnsUVojMJZWOuO5Vgt+NNHy3v9rA8Jmc8p+OcZgVKOqI5JqpKh6Na5ST8GkewNIXJU2yD8sbEKadTLDy4kdeg5Y11X8QvFd9DJ3HEBPPhrsOMcGt+F340TM9VLMazbP7whqv+3Z56rlQpb2JVU1HDuNXLatUk/3Br95HIEQZEVFAJ8bm1i/CweM8E5OdL70yTS98b2mggLyICCK48ZpGkQoVCnQaZI2H7f7QtBxU2QWtz1I7IwUFPWX8YQm/pcmDMy3BU + JWT_REFRESHTOKEN_EXPIRATIONTIME: AgApX3CFkAkpqr/OSOQ3y0JqjxQqpWSeaxnPNZAKE6Ro6e7WaNwcpLHexOXp4x7MEhXuEzH+tvKbRtssejNNEt90rAmfNaMUO16O4Vxgwz2vCmJBtlheMLFnwjnFmV8iQ+xrROweMKFkFzCOS351DVr2gUwRPHj9Buz/ppc0i636PaGhjHuYhxEPTVLjTc4R7ip7CK9YuH+9OCdTR2ysHwOkuYljxqO9lyjmKYWUVavEygxm1oljPNh9Uc/ymnKURNFc0rZUgG0XumyJzWE3BnLA7esIDDlEv4mmN8MrJYLYB/+uNWjYV16zx/jOHn7LU8WM6sbDnVRwsIKzn6fxdiupk3bc66Hn7EiT3qUJryxJrWNRmiRhHept02iBBHYxC6duB3QLl+YWm2miMq+uSwKaX1DqqhxsnvSzpQsDMX6m2m/tpfODs6HblX4kgw0vk59dhpwR9oCqhXK4QttX3UNMbXo2JHvCKW6D/4KwnZKdXiq/BkT4mXxf1DEsHa4kOVSDtHoywLs7rCPM76VLa6KLHvvHKeoAqJYFmybK2Ivhsk+pIhIw1BkEPKFAGQRH6NZb7CqyS0kpCBFzhiCMfamGhmmAt8ndLWYDNP3+cR6HpFY0Tn06m58F/cdrz+wsIMwm0iVTPwi3VolQLprO/P1EjEdd6DkHSXaat7FbnpMIaYYsTgh8wBmtuAV0iOPHa11oHpuFhxNLDRbz + JWT_SECRET: AgCBfuFHQw0Cc4sCs7R2Gcae+jvuq01cdPMFpGzKulQ35cfIj+Apn1ABCuOmqBM0WOqZC7j1moKkNNM7P2weH9R22tHcDLSp/qg+5ZxVVJmpJauj2i5cEhIvzrSx61433KPEisIu7tkBEULCWDM1W9HmFkeZ/tjumoU1M7AFrTAhaFwztD51m9b1uWrg7ZIonMzsabp4w+CewUsmZEA5iTUEdVmtasfHN4eweIjmIFa8sa/BBPraEAPTwujKTnm6+BpcMeyfACZvnY4hKLS1o5kBE/N5IzacKzTz27BL4ZJ1ZCbk1zP3g1uwEkYeXQsL5msFNdSPCkHzfEuDfq1J3kI34R7AHdWvTVjEFNiTJpjv1fDtK/tefQhyPDQMlTEzA/aUnS8HD6tx2UMCdRn9pM3KnKxTF4hR5D9TRFWG1jkp7QIY/Eel+NQjnC+TT+tCz47oC8c8SiUapaz27DZFNzqdf3qRd3ZivufgAHFZSq6QFAtwkg3Ias05R3MAmipYG3Ayt9hKF4Uc+RdZzQcLduri0t2qXSrBF+3JM5e9gMzyoRK5Ecn/avtKJzu5e2j1RWycMAdfe/50hpdTniucgZMN9FRxagy4XXGIfg/00328iV9Z+dKwT3MWsFId/JhcZAPYw509BGNHKUkvGBSEOcNFcr1cqi26/mRDEtkD3u+rT7fa81A2gRnjoHkqsxC+spzEZczqM7jqTYvgOnmMSEw9j3PoGIt/495/YZnv1ADOlQIgYX0TSKYDEO1V0aTF85pAYqyjr/eW8E98uA== + MANAGEMENT_ENDPOINT_PROMETHEUS_ACCESS: AgAHNBdCIAou2gWHpgNJzeyFTSPfoJi0o9Oz/Ka6jYudwYIwkBBOhLyOGuNgv8WPZRgGPcBq6gzcfDblOaeda4OFgnDcYAnFBFFrFcE7a8JDJgCQLMuQ9jorEkPijXIgHAc8pwC3NylEEG9mggTH9B5qpJvi6KbQSAQGmJWMav8zYWj8DL5UcLBcRC6Th8NSXI+0fiaOv4Qtw0QcVvtfHKVYhU129dmuuPTdQ2V9Hk4eB7+tzMOLIQzuh1BpBXPfQw9gcwASf5pqXUAemhY0FxmKFXuXCSG68FSk39tqszBVDj6Egs9kzo1V9KRzfHJfZosCjVKDuGZnP3sJAtK/P+OgD4cHJE4syowQ4kIJV08vRFpSq0k46HALA2hterBbJpT2Hwx8Jbg7Uc0CVqlf1yYev5aXMDmokCFHiNKzXan0iKUqrbSNiNI9YqIatoqwiBQ+xeq4Oi3wSpx7ywLKxqeU8Y41QjNT63DlZBaaS92jE5OVSwTH1NkjCLPilzrJYwN4/D/rYZAK4LjG93h8lbYg2/nmkYgCCWu8tvdPlDw1rjsc+R7K6SRbf/UBtURRJe7gvIQI1WXNKDOovXAh0sceMJtnTfTnvOtVIAoSu4e+pRdCm7SXzTawWUJzE3fGCTXDmMgde3C9GPkAkV3ig24AcxCNYl4i2cAnzDfnKj2LTOSJsArHDG1kmRjGEYGAL5XbJcw+ipUoWIA= + MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE: AgA0G9KsThAzr/FSv2aMWATCwrJnZ9cyUHxz7wQ0GVX/LKBwbRfv4CBIiQXTWA1FI7t1YC7JG9Flkb1Ro7V5deGfmhDP7Xi8br6JeG1mghqsra9vne/vsgaGeg2yPpXRoCHjlwP0BFOanpkVYa85cznQ0JoBQ/6ORcom3I26t9mdH8YPO+WbS9rbwi/SHjpbzw2zruxYYlWXhHe8hENeA0+/w5D5qQIke7mRUmV0c4VolHlbSGGXs4uwCmwOxLmRWzeGqjMyNjBtaRVCP/hoQeAIs83po5SByPPjjCYHoaBpeZZbqqF1mC9hG9Hzsu0Ofis2vUI3aDcUVHEaLPOkxGdlmrqeZ/KWIlegMC8516/ryBJS9EYaYWQE7GPoejiu4RrFU83teZQY6xyINkJQwraJej2JOvlC4KB5DYQQz2AJs9/bUSIe+rTh3mFABsEzviCWI4a/+Oa15yB2YaelKezIYs7IX/vPRDlL33bCtks9FBdMEpthq1l3LLZ9B5RwSO4W5fjIRpTyhFIzJDsKrvVbMBc8VN4J7k3UIW/0MERXkxJxXloNMyAF3VbWX48d1OrrPquyhM7TO/rJ9kci3psgY03JzRBPE+BmDqWXfbatwfAQlz4bhI6YgsgG/F4EXYqCDoMeOWJ/oMS5h4Peq3cpNnJrbMvz27l11/4KXh/dTHGzX9s3oH4KtL9ol89VdgpWEBJ9/Uhxms+sMo1FHaxzAZ+Z+xMf + MANAGEMENT_PROMETHEUS_METRICS_EXPORT_ENABLED: AgCIHFnzz7gr99h2V1iDSymfTwk3hsc4FDwmGVTqgkoMfsNrHPCX0xNaelBoZf+kvnhyTMl/1LG43XK1ZJhYZ0UNpEDkMG3LI07H5B88U1mjM0MGIpNMfrtdlS4lBKrnAKk0IGlRtacI4QEZifV5NcgF3w5eqeNSBk4teXdV0qXS5noQ6ZwDtyWL6mNmtQdZb1L497wcXOcB6tRjdXW2szIaJfd6rMywaj+nUpg5gbGp9St8sCdoCRVnL840MsNB2ihZVHaA8/fNr0cuHcJC6L+ZFeAC2nDZNt/VB8GojUjQPLNLTHqs7H7/sJ5Auc/eWTw7yMJHmeDqCSS3n82BZmNcW78H7m5E7zf9zxMrcgJZgO9o7t2lYm6zCTdqAC2OivSPPZ99PckFZywFH2v6VmznCItzAl0nhEvh3WthbI2R0e7DaGL2tdcnxvinCF9u+GChEYpBxU03XiA18z4bgGP0c7+7WRJOePwjGxgM28W8wddTV4Kpo0f0acI8HX61LPPy0UxyNiWMJ6/e9RYOrOYahu0/uMl5l8oTEkBTrx680+oSwTVUoKdSxR4lF20HtbEwVbSe+0Kg79nU6l8OkBYp/wyrAlCVE5oBpoebTCVL0GkPw7/EnGrZdIKiP9Vu39Lpgh3gbj+nE3ZiVYnPjeAvE5L7VsSudI0oU/zsTF4k+vDlRj9e7ndQl8AbWd4/VQmW2qV8 + SERVER_FRONTEND_PAGEURL: AgBTz29XF9j2M7QoVlpZwcXQSz0TUe4F1elUIr3ENWNWmq4AaKx8T942v8y7ZbRaWa94hhenzYu5NAM2TYwAnT4JSqQqG78vEkxQURScOWSprDCS1XyCXfPzqOF3tdWEGIKU+7rQlHoI87iiKFZvRQsCBYOnjjl0P/gt9sHNCMw+zltEctuqRkurXTcOPxjFNuR+4AXN7tGI7GvXb+3fGINw8U5liUs7+XzL9DAqj3M/DLdyX8dzzIshxXOsp8Fn2gO+aRQVmNJCHztUBT43ruiLrsVV14SSTT96Aru71jAfGwFdsYpKR5kpTByVmZMPoh/gGx5bn6fw5YHxPGMAxupN2BMshrlfCKuGJ+S6ywj8lyEwnxWF9AyqN6kZ+Kx6KWuv+xeCL8E0cJwJMjiVOYivbYPNapk/GwoHlpDejy/26AlGGHTGot84jx6jMS507LCTcZqFyw7GglsA9nlTDBAX4gmsrLmG9KtbrvlAZIKbfvjI9vnw0ba3iIx7rYRS1q5pKcr/aY4nbS3udatXSEXEBYTgnX8/voYepr4+tb93qCsPwah2bd11aF2nHp9Ayj+O2TeOA+g4SvLroWaMhZ6QSJ9aw9r0lkfSw4yZ+fcRi9yNk/+tGDqOcSKBVls+KpqlG8HbJFvKcPskBucRsBSD5HCsWxI/5YC2ABL1fgKS9cqWiItBYA3R6EQl4tnlhBthCKdIqk0v1Lc7NWy+v0io73PCZkKvu7JOKIGG + SERVER_REDIS_HOST: AgB4QahicOAA3LHQDo6K1ZpZz6GAjYnBv5MTCd++6cOGmoNWKG2MahxNMxRC6uR6kBL4pNan97qVQz0eGET+uYGV7UDe5zYfK8ivX39vNonEzy8H/k1NModJfuARRcRt358R7Yi657XA70bbacj6ER4dfwC6sbVkBvVv35dL4Skvemb7mVpMDKWP90FpGOC8Jf+VUEANO47KM61gD7b61oleWL+f2TkPEPNgPBNaQwFFK2T9g/l94OxLHGgwuP/6zGqjLNZW2vcc6g7ozhJaqu/RwloJ0apgVnPDYW1m0VfG9/m2uMBLvmo2YmKg5oneRwYd9nSeCY1TUyqn2AnEeu9fUNlJxjmeurC1vHCOIM/GHT7FwEUCaGVnmRd8H2u4s8cxN6ZJpKsfv7c2AyT5TrnRga6XEt4hGdMLEeAzLcK3qJkLbKgvLqkZro3KQRguy2PzyWorQAVvcZ7/mxoSk50SFpwGIy9qpmsmlHU6qGQ1Y33YHWE+OpLb64YqVu9XC3V+X3si7qzfReudDl86RTNf2dgZLd0A1aZ/IT3YdWX4GERMzSrSnPm66DyeE6duOy6fedIYvItCc231z9yku9xpzwlQxEuiCfUnlTFGj2ugLbyL+4dZhr6QCnAqOoTFlzFQ1tZE620nFzgVUU7D/+UeBJsfOP42MCEEIJro7bYmmTFbXJ+VybaT330AJ4BWdbHHaIQapCCwa5IFfNYOloOhMsCk+l4EA+hcO3eKURUHQ+bPBgcd4DZiGhtdJsk4mzI= + SERVER_REDIS_PORT: AgAHg+QlwP+qaFH4L9DRlWSg/I17uKo5XJ+3ZZ/IEFNu+sqdjc+Vav9vNrw/EcjrYBxQeP0ky49Hauq7kgxTNVwOuEbw05DGhYLUyzcoNc5z01UDSTWxYmGqSAWt5h1KvyhjmchgAGB0diEUftRk/Mq9clCVXkJ12r0v+lWPSQzfOgvMNuGQOnBMi+EUwKXySf8qZ5gfwwdWelQwYZPniQcBY8BItfvxpvJC+NCk8/kD56hbhkWP+1XhbRMcu8lTESNt2QoNn8FP4k+Zk4Ki8k2rr4Go0oqTc1u/qaN7FGKMzHMcEcniZ7EXELwdCNIPqZsx2t5N7HCVfxto+66vb853B/R5sslE/H+Xr8EHMg/1BPMr9JKi4fw1yJoWBIGyNyG/ikBVCV3/LJpAI7kQpcyQ2gk8o5M0a9zplt8pL7Lh0kTVZwMTLz9eFf+FwXSvTWKjnvJxjuu+qQtcPIl63zIcY5PJYpNm9jFCDNafeQDg9U43r8x66Vf9Fus+Wytw54R0WdIQsuW8RkyQmf9EC9BjDzNqDe+LMY0+BER0y7PcCfZfasy/suCRipYlf3H0orYToZMbui4csscdNkmZ7aIOLcPW3oCZHmt7LpM2WOnVsrVOVYjCvJefWeI778F+rBZYUF+ykxJtkNDyMugoJ0EvTNV4CmLbVj3Do+AAj2zHhxCVybmjg58rn2jBGG+S6373CFmt + SERVER_REDIS_TIMEOUT: AgCSALf0IDXi0pAZ0J6sQM8tD61f0KhtWLXrF46CGDieMwS9AzMtNaeli+52BnH0v2AtNptnahZTjSEYtW0CnaTmaEM+MYsX62TVDygAkka6UjX1mJUYmKQ2NOuhel1dwgN9kQPHPkNzlfMVpL0UCb8XG2qP3UnIZJXa9y9Kh+hhocQZ0WV29e4YaUdub0rTMnUx45ullXfNqCNxQCKqrmF0eHMqXXw0UnHPxO+WbzAa8K5SN2BMmbivYM3QT+7cXqXUiMIj4OWbogGGzK0Rqmpnn4kuTqMBqxkg4peA0YhjuPEmgJ7FWegDHoTXvCfg1Ebyz0HxyEI9v0F5KPMcvI6TVVgggi6YjkQzm4ae543XflQcasoIA+jXNmNiIo+6Z1U8ocZFUBcsYeO4uJC9vdcCDbYZfnb8WYAPeHnOu3Med/E9+Ec+hyoGZg1+kJDpo5j6HMbcJdOOmKSazoNmi6Ok4z0NI4Pt7o3iADFx/qaxsaqpPoSb8JCHEp4MGDIyt9qZBCn9ZdudxH8MyhKQn1YuhO46UFev5Dtx1oAwMn7GYYr/NIVKdDGxWiS1ir3JBgeDIlHM74V/kPWE6HuRb1Zxy/WaVUJAqVqwUINaJbSLvXjeayHF8/I33xW+fz45ZVQ+0XdRsico8qH4AnoWvEr0m4435CWL9WW4OJ2iYS2kwsGv0MXxSWDF3skSDBYzBZQdf6wi + SERVER_SECURITY_ENCRYPTKEY: AgBZ0ips468b+LEMiEO/koJezE38ZXairQewNJYidPPGekIQv3wdc4njP4qHPHmM9UoX4+QESz5GURm2atNR0BwSeCwq/+4B3a8LhMYa1tWxpfAkU2ahVdZbVW5y27zykWDyFscfBhk6E73ILdbAwRL6FDt1tOi57EZYbEKHsasi6rB6xPBtXcJdSmJiSfrCybltrMi4UxIQ9YHV7egtnDbPLA8q384FsOif4JIqo3RVlqioUvM2QU29NbPKSZzJG38eWVIo79B7aPpg2XGr7FXo2oZ25Em5LjSxdyCLchRwz4CsMDWteZlzVR8zg819iWN2yE7lHdYhKqnuP0yfAVizVPT6N7kNy4WmyuWwxhKJXxuRSR/fs4Pvl3mXB89gg+Pfi2OR37fsjbaw+Qdx3Jn+ISB9eeWqzg9XFtnAI1REWSu/+/UQdqA5hhNcbUzsX4EHNTa29juJ5oH6PFQmsE+JH7tJRvSFnb9dBRcb+coUtIceAjCAuUcQWmW/i3bXr3d+NcMP5SYLb4pajKqysTMt40mbsMhOsJWGeU2lMSERkLg+U8WPKKCsZRHiq0RHBkcQJbnEGnPWCszj+VQJ8GF4ndCQFjMdAQ+jNaJ4Jwjbz6xYhoxRiklPdJ7cA/b3HrbQMLu+hUQFwhVpPeAnnNa5Rrppeg2dBI885XlBTuQxXXN1x1gQ5fkveRMfwKaO7KQ2z4k83jQtK0o3PVWbi/Y0 + SERVICE_API_KAKAOBOOKS: AgBkkakD89PJ4BNLMV+77a4fmsJRiSy3dq+ICqpsda8b+C01TVYDbY5osJKQfa9r5RHgbR+wX2I+pa9cH9ytCBMogWBcwwtnoQPem5qUI9ZqiM2Q/YJYx+fKeHDP3pwnyf4L4x0UY205sNhdi646mZahVFwwQthTH1NpgF1sffQPUlijxCoDTh9WWJqSDg5zm8RiFHJMSyQnP4VhG6ZlkLjZd3/F/q5xvZf1EDOx7tAOl6wsYxOuQZqYTWHzVaIwa66vyTawaVK9XtrbAw42PJ/mEXEHIcHxNLRoU6YOCsG5QmONLO+Vk7jkRpbh0MlYzqgZx6u5nagXb4qkejTUeDr3qFt9pfR8Bp1FixpTUhlHkiEQVLd+HXA3egSCIf5JpQ4GRmcGq7vcRcucBsH5bBp2pMhtO4asZyLEDQyaQd65TTAvft4DxhWzQX25z2QI5+4f3r8UU+eLrJQE8a0Zq2gQAN5rkuh3z/TKoGZPcmG7pDpzuuK49N2Om/J1hriyz3nO4VsB+Qt5sKCatT1USrGOU3EWn1/+t0vAOH+eSv8It2kLM55n1QqngHl1+6RQHeNNHgjDrmDDZZfq+CZTbiY2zqixl4ALYAD8GAKC3GgO+/COF8TDdoHssQYfyCksNJ4wMY2a/8jzcum7/ekqUiXDcSIBU42VqgLbyyY2jls3EHitfyW3g5d+M1YEtfmEDx78DeQm8kJtFj5Ekmt3LgxgjqfKmAYL3oo+hy+2oEEPiA== + SERVICE_API_LASTFM: AgBi4MRe4wrCI0wvsL1oXfOZRqkvuHiCNMcwNBwf2JAj7t8o3bnHU3ev7Z+7IAPSh2wtz5XBuIJdRzMB3uY9CE5z5Kc4UKuHYedwg+zig+Bw3xAS8PQNKYf3252A5aLwo6Y73KVc8/fXatkp2xayMeZjNxwHatkHbk9OLhrog54XIvP7SiNkD5s4CcerHGAmn+WG4rsYUcbHbjkSer97siCfImbC7JbyJDrv9KdZl6GM5KZgn7ARLs/YgkWXPH1SqTy4OTi9nb0JsVNK0lOMIMLMENu+ckrErdORXUQYkbOYJJhrdskYE4e0kLPqk2vuTzVLaGCcGNdxy0CtewpfUKJMX+Ny6Tdoy6c7FM1N5TlkytWAUqt6tQxbzDFtVZE4sNzzdpUfH7DIRqxB4KH6GQ9Z9iI1VJlQTOytosa5OUzCwBA8xo0QpuDYHO5ME6HOczPd8sxAg5A+GSVs8I4QWp+LdBR+HP0d7hByUSkuy6036G+zqF3NDNUWq/vrjjCNqLZJwWjolC7yfetLPYctTNLmDVeAZiyjvxO0ujZ6t4zJpStZnWPIoHTm/ZrUYP2FHT1CLVGAzXr9Bcm21MvRz5f88zLnccz43Bb8A+ZiEAWurXp9QWOQ9jh1fshRo2KuYLVrT33szlYlaw/6k7UgkjI1XJvmKcktbu67KKM1tj1hxvAlMbHRagbbHLG2a3uk2VXaXAYxYxZA4pP6366cFOTwwBSloNzyukxb6zT98rFhKg== + SPRING_DATA_REDIS_HOST: AgBn8ESMW5IZ4RoDE7Dn9fckp5R68Fv9Rp7l3rfafue6aNGuj6A3drQfKtTaHPkjSxpPdaGE7xTtyyj/j2JLzBHRZUgmDz7FfRIv10iU39qdDb9N1B2+j70spsNQ+v99UZoX0KsnDKmPN6azGp0gWctZUuS6QiYBZqFRIwL3Bv2x2QoGQZcHfwfD2ZxkIbFhG/C/XCGgmyumBCmN8tVpRkMFAL673G0XHiH0cFq3QoXrk8xRc0v3awIrU4u1S8tvWHU+pIhB43hyFK2DHpNy++2tqrQfEVU6Pxi8kApbzTe/laRg8a2gOiC0r3LROjaJtlIRWf7/iUtSrHabiugkvTSa1FCDP9gTY+jnX8AwVWDyRPg2kINES7JVDqB0huyINXkrPjxyyAhXqZwrsntpQ/7M/yXqwm7gXmwbd+/MhUdvg4cXboJpVp4QcX7y/yOAciW9zcw4crEI9+v231d4JJXwFejUhIvB2QE+Tc2FNT5myVLJvmGu2o1wzwVSNDTXICxooMWzOY8cl/KZeNC+FPU+lAowo5CPVPk5SHB7COfA4jyJvM/iRk4Qknmdu88FasqJpzcdLaRuiaT5c07Id8ZWDxa5Pbew6Kyb8upYS6xYIhFbGacnBiUv2ilKhAz+xgdRIVDiWKzYrQha0JE7ab1Oa6z7dWoqj1gWEzYM1p1wa7Gh8Ri3GgtUb6v7/FZAxWVaoqYGhehSs+x9uhDFGMbPu7RidihGzF+k+j88U/38sjwxyhKuzb5QHW5ertQPNGw= + SPRING_DATA_REDIS_PORT: AgAoyeE/XS9DPMG93pqgg4RRRxU4O1DclecQ2kcrx3FHiQejWR1/ExxkVJjFNr8jiYMpVDfnzf6SH7bJLWMbZLNuwtWEj0yEcsrvDV9vbWAq/2PF0gv8+GUrnezX3AmE8a3hZzaAbp10rjkS+B3/ZDL46ugZS1IjqKy2hmYjK0NaRyBqKsaOxzrj3F2KOtT6tKv48f1VhL7XESZamrS7uozSBIe2Su6GsRDhS9VEvb/EUC0VNi5b06X50T9Av9nYcJXYOFEwTBdLfU9MHfwXX7ESIUqIeSMbkoHgwOkob51QBGVC5gyHfd/suFdGu/v6M8JOZrFq8o+g1/tXnUzWXL7tUM3sMTIRGhW2BTvF/sj1gDUsuSl0qJyq+wTxtdtThsQS+EcR6gTAvDVUs0ziYtiSBdhJ2JA7xpmBu9rJT0mDBwVM5VsMltGe+vr64nwsvfrCinqx+0nfs5W63Ok5GdPSTPYXwew15ZGIOEf/zWpLjjeTZV9QZpnTY9KXQT9ecPQScPa+LxdTp1G6NIMTJYtMqRqankopnW8rstD/NbGeFHnB4+dawLxcoePWjUZnlnqPRj5URyp0HlpQzMJP8mQbHc0U7kUWqGpaDIuGNDOMwXdP1VX6l2HEVbISRa2jsob4o5OCVpAuyM0805KixZ/GGo6SvozIcWJ0n00swJaNcM3ZB+muuYS2EjpPP2yPbUHCEoC1 + SPRING_DATA_REDIS_TIMEOUT: AgARvu2np3az2pPmi7eiDkyUi7+hy2COoTyOCUaLv8pdQp4ZWHlPCxa0NqkehqqiJCPfTkslpN5oULr3A8HgR5Q5/6CJUmR1f0/Xsh+EGR2Z/pY5i6UG3en2kuuXJy6InZgwdDbTkq9iMSqm0J140tapvnzbRqGEi9Q9yCzP3OJeWm/pNNkN4nQmx+LkqlFKk9asz9dMhbvdDZz6udbckC1UV7iNbesrm8OdvgaPa4keLF8YGNvTIHs/tIYA99Fm2qMTg1nc5BhTTIk7OGy+n++ZbpVfIpULDXdzdDxuOmj1Li7vc4OImehX1Gj84n/yKRmzhYwBJW8GYv/ECt/ydGjwqqkZlCiFWgTRSqRjQqKx/+UPte2puEr7/xFlbV7HeS3WEYWm5ai23VOLIehEwaF7NIMSuIi26dYLpD4OfHo+giyPTfqpirOAUOOfRzdIWLzB9q7OKCevpmfzc4qvG7nsR7taNCXDS3c+etYE94u42fGiaGInBNZPzFi/EjnoRmIU0InRUcX5k0AX61rP06Ou5Qi1jGQVE380MU/xvfN9tqbTMH3LZOltkjbiUqAGwxI1fru3gkKxI90JhBAaqlcQrXpc3SUjyLu8QPPQujn8skDWXjR8wDgnGHCbA21z2QpFMAf3vbEP2NASvpwHSZpLAR4Bu6GK+Ogr9tqOGb1X2pvBxt0gLRhHeRMKcVNSIkrflTui + SPRING_DATASOURCE_DRIVER_CLASS_NAME: AgA/LpMBQoANmidPU4IQkp5agkpRi76NGFN6dCqBQ7ksAXGe8P14jooMKBw05OaNTAPNCC+YuiYevFcOE6bogvJjCFvn2BKJHBLZY+oWqxe6wZZfdIm8sj9oeyT/OBwPDrO25wSQfKrLwgFkFX7MGrlsfDo4hrfbdxOTI9uYwyOyXlunY8ohe4nZFI0QWz88vuW9YJZMoywcykRY9G0ZqHsnOIMLyg5EEu0GKoiXFq54wfM1B8KATszqrhywHea36HVFHnHmH1/Lc5tE3AUoNjLeUWUii/NwyCzLHts/HdnjWRDBY9VUpBvtVprvL7dV87WDj14gwDGpqUJl1AWrK8tGumuimpO/7QD5fa1ZAhmKC193GcRHKDd/C7lUAtNxGSMnQTZ/VWVh//lfaVUAqkDwdzVtlLWAYE/gH8AR/0jnGmVBY7S9gDTXuTSTFsBkiys6d+cZPZWwS2Rcc7Ise+8sMU1pVkyBQhFVYAJR4yHq3MTv0Nc8h8Vnrb66GjwecEMChKrKyw2ZcXKugldDeKjgiKXv1T4Lx82qEiP4DF0Nm/cd/TnzuidP8Ox74E5v8hKTxGzbw5dOhw9KZLF/XmcpkjUmRZ0n8AqPVoc/lWqEdwx6K8XNMJgQueRgfXAeQqLNTAQ4gRWvMDYcmvUgV0WB107+P61nB1U8NB9BlaulcOPqpLUPCsYV3DKySnVu2YL9Dr4HJBluzsQTxEb7HtEOmLxbjznJ2IQ= + SPRING_DATASOURCE_PASSWORD: AgAPVxVhmHUl5JbbkdCR8GcH3l8ngyqKWbvHj0aTDU9NSjdjjKnH8QIFlsNnr31+XH4WIhhqehnnXh4NsnpElhAIAACYHw74waUe2HzTf79S48+wbQor+Kvm2B/dBE9BPn2IbOsvMCX5IMhl6prwGZjmhGIkGzx++o5pOk1vRqZ4ZRq8RChjzJ0Gpt92forSVorYM78mDxQ/p/Z3l3EiVBpyPvvLibUoo0f8HGZeWWBP5G7ZCoWBFEpdANmvXL4WJsQr4dE09KQGmzFDbt9XkcHgdD7XOusbpIVtRUFBj0ON3lZ4WZLBeJg75lWPsu6GJHalKhnAXQuc+UyV036Bhqozq8Jl6dI1q/368oy3fx/x6BDNQ+lsaJZS6R8GQmvurqqC140xoV+bCnr8JFSpmd1y8T+BzRkz6sJzJH2ChcpSC/MKHsUO+dxLwVuI9NEavqJ8mFiO4kPHsr0ZpcbISGCtiwF24Ae8l4b7YZxej5Y8UfdqmOeEt5C2C5iH9RPeqrJJYhvwp2GxqP4esOFAh23jqG3zsX4t9pgdGKJBssTXeD8oaquGWcyJ719BMn6OjfiYGztuCqCbLKDL+sj5y0Ik3FU7URJ5vEHOPLUs22KTQhFtOWBgqGgygGasnz7HICbLNuaunL4kdfIc24TgM22FCFIrrrEUurPaHi1EMgxXfv9+9ULU5d6FyzqmfVQTaRtMoiq/vr51AQ== + SPRING_DATASOURCE_URL: AgAtb0YS4NzbzPkQ7s9FIZGnV2GjNXJdhOr2CPqnxPih285R7aEI0CoPy276E3rdQHp2Fp6mtifQVR6T5qQbYJ8ub/5M9mvLRXLlWF2ikezzkWmRpzHcCmw6CpFyP2sPlViUU27/tElN4y6oDyQDzT6ALx9gzGB/UCREKSTcbe9ABleB0Wcx2kK4Uo5+fTZ/tC4kh3AFSE5KGfpFiKKeG6h7dL23d4usSbg2XRJPea8/S/HmDJ6XfESEzZO11y6tRagtsP1YUPphrUIsWasua2o2DVBTL2UTpjm4rGNmfAFp9+3NbBTrAuZ3+y8G+VkPw2nE+oY4Z7T5OHzPZydAqly/i6Re5ow6mHlIZh4jxOK86PVwLjA97d9xmoBcDx8lQPJCFY9gdkCB6pUIPxVufX/431BE5J/K+Bw3XEVCGGl3uKytZ4dea34T9FBC8JuEvLdYVU8NNnCOdP9P0f+HxwAPUOIlXBAiWql/dzdd+S9Wrq4+pDDVPitz6PSNqFwjRk8ldKXlhkxIbd5sedPN+EHh63JDnILZ0BRB++0gOo2Qi0BR5gOTwXciNo1F3jYSwsDTTxqHT72RG5M7l4d7duGfUH1dig00CbSXxAZswpsIwsTj+FWuwiR0eOG/NyARRJ/JSlgYon059sAwMpsvKTRsHtwxORsrYsr5xsTIpgPbaiFfzEW7iRFW5VZN2ovravH0jVPxWwbb6zmr9ts+ARCFBe3qy1fnbVa/LpTQ7SJAXas4UWuqcP3pluGAs2f5KelQ0CCuyJ2drquhUYqsM6BoiY9OGYYROX1Bg57zpHDkfNHz13+kHTU/UjGa6lRhNB0TesxasVRuTUjXi04swSSAQw0fVD3vx1Y= + SPRING_DATASOURCE_USERNAME: AgAbMaJsJ/3HCwa78QX62rXgiOF9+yElu+y9ky46X19bYK/KStabyooX7V+4Me4s8854T8WPApnkEi3io5JhwW6RGZk49HVofF9Q9UFyi41YJ/rnF5dm9CScZ9KZUAFDJikyZu2g8yqtruiy6x3OkGIN4e3BBiY94rO5w+pM8Erxxn30fT4MyBt4oYbwj/4MiWilXa5ZrrX5shRt4hLYWyBR7vBIU8GPeW0OlMSOE+N/3FUcMZqAGzy/umJoLq4hYnML2S2r1b8/Z/2M9mIJiW1nGggfai+erW1oW2V6hc3sMdPrdNFUgcisXAT3f6agFyTlgDNvXtFYAjPFziYJKZkKnqB+nD1AzNXc+rtY6mRUOGww6sev5ueI+OJ74uFrJBL4wPsR9fl03tmyCoaqoMTRdMZLtH02KcLj1JqbaGG/23uWhkDfU65plrvxDiyBKseI5ElinlGAB6x0e0Lt/yg/maPr4KrKCCnqFiSp9gD4Aiw5VXPM4zZW3FP3fQ+7ujuo2VSmHciEwaj0OKB3rGs4gw2wTT/zdHAjKoi4pdyVrlrGEy7w+vJTfl8SrpSEEI4Dg3CzLzwVv0vwC5VNmOtZATrbeFfeSEm5Pxg1LcVUpadz4FokBgMvrHH3cCdrlm7aLWHgdx8hgXQMkN5rpt59mLyKHxZ0S5M/ATBFFJDQqVEPNoiKrPFB1CThhsDuVFWAF/Z/ + SPRING_JPA_HIBERNATE_DDL_AUTO: AgAXANCAP6pLQWuC+V2Y3pUR+YV/SHySIbNjDfq1/+a2BZhqMGvaJN7e64qL17CFC1wy5Reyigj1d9LNICtleXrVfvbgiFo/gZ/5UWPYjU3ghdYbiab1JAsLVuo90Y+ZA0HyXuW4ZOKYXIEclZjl5vmLiImLPaMbDD7+NBLPJc49KBpxwaCZiQcDoyUKoaAfYYEp7QCtakHxU84HSEhpU2Zz39CDtia/n2UzOO9THGLNCX6oYZzf42TP/oEJHGI8gsA49YuNtX1uYFnTM1YqrRjcZjjVXoX/daxfGh4gf0kwnAc9DE2qvi2eR0sG+rkhqIKGWD3zkl4rh82hpMkyYvM5oqjdnD3rmvE1tfcahjj3OIAy5i//OP8lF0tuGeU5BKTOvdqR+lYsjZ9b4XYuzld+huvWa/lMnQ0Ugdly36BZg0D+dOge+lgTbZl4xpmpuSSs9qqL18/pAutRjBviMs0HPLg0FQWMaO2auBDKrGag0i6Yr/kejQJKh44eD7NxsxP4Mx1durPLl9q03XDuyLz/PHizhzg1UuOogpNh6Vpkrl9mHDkKbUr5bz4+F04DEwL+6r6NoDz7OhKNqfERnCKCP+6OUqw9v+tb/S1hHKvsdFZuKpRVyc8zUz8m0kCEYI9s+pToAQr+fuD1f9J35Xcm/WvX6SWPirN2H+0EVKMH00HH2fT9BsupQwi6z5Bg+IdPB/FfmHo= + SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT: AgBYvJ1p/O9PlWY0+lhxppoiL5FWC0lV6nOudss37fgUoR0fOuHcIkO5sMEw7xMPOubXqpUOdReV3U/6ym90qkjy7eLVBPfb6KhfthgTzKoKRvDgSvOHGmTndFwKbxq69nXyMNcpzpwhrd1ZnmlGh/IA2iryLGABRAYvfd4NlJapelpJz7tlqASaFsJln7HzTsI9OmyzIFhBGYvgf1u+sU8lJfk4BrOex8Np2kGW4nJ7obqIe4wqKLrhR9X6wKXBOIh0aBDCRHEvF1IPTj9rPEmURtDHOWTt6/PpbsKu2LNAWrcRt8RyC7To4toUX/L97KyrfQkTWCsJqYtg4xP44yLW4boVeMYDSlwsurgjQ1LLG3K7w/y3mPEyYlyK6uda+Msa1LUDtiWqnDvmQYjzlOPGVPrQYFXUdsr6mLspgacJMKUNXwVwtom4OrlNrDfXrTzbrQsou0wXyWlbAaBwL5LmR3hFN6bczrKvi9MU4X+aNjVt94C5x0aeOQ4uuMYyM5ec2dFBgbEWUUQts54iDMQUwFpVRN03Ab7FrzdcptMiTM7oq7mRvPmL+msrcozsn7Si7+BzmeriSyRR7ktybsJ20mcGtB7+Qp5P+SMvoKH5mB0OxQ7f68c9Yv6itnGguy6aLUCMsmS+orcGl2oM/lsIGT89j23DE21iCQnor4nUYeNlAT9ER/jiu5qxy220JTsNex6uhsN1ZsJfsb4HJJL2PG0eI/LORgF30D6+wlgk1nSxFQ== + SPRING_JPA_PROPERTIES_HIBERNATE_FORMAT_SQL: AgBiwfWe5+oIh52/yW1veP7426aZDnIQBCDIF6vzAJLU/sKEVs5M4+BVCmkPXzUg/c8LP72VuzMdRxAne8ysz9bJT3vVy65RSJTTakicnF7phKT30N/imMdDjXJSMI5k8yn0gMUnG1WiM6fDbvbX/2RqbSc+TAKZly9apXEPFj3J6kg6rccNKcYxIHE7/NHC6fYsT5jRr4g49Q+7amAjiNl5nb589FGoj8E+VHNJNfbjfIkUN8sYcCokLHqDXx3EQZehEBxSaZ8ivo+oKj2pgyPqpEmOzgECiFE3GSXX3x445A4XAA2DobE82q/E06OFbp7WOTzuhShossezYBef6a48M1fR0abACs9KBMMzJgDtWLqtZGRdRpkD1TLaScneSNU7IRWNd5Dzmr5G7d8sNM4zXofDxVG+k9xnXMzcmX/hnk5vV6eS9Mc2jAodgGIjTYz7HNCr98Pn5JXnTSxMfyIkBOI0UEZaGmga4KTyzrelwxAVjYdYFYhmt4EAJ2xg08J8hLYenG15rQp+Lu2YNcMKGPVcOYaiq/K5zlsHdFub0kgszleIF30MTgwg8FaaKzRdAGN8hqwSnjzFVEYjrTxaOR2tvlp/bVVVT0PTrXFuNIyjhIhRe6kKnhow78ZjMnM1OYU3VIYS2aj7BOU3fo6hpngy6xbpCo+gJXEf5UfuWigCTIdWzPqxlJffIhWG3My295Mz + SPRING_JPA_SHOW_SQL: AgAjSIbqHUVvtbxhUF329597r+SGkBA8QGkv7B9uSun1yXs7N8f2+dGLbAYa4z28PWQa6Y+6g/dqh4l4BRe3tBpwOVASMXjoJYWRgnOdMnUgQfaW5D1G6DYQgpvFohCryO0yu05yYMSY4ukLD4/uzZH9Uvp1QgTHmJJXtPVaHTukAy+cEIxu+dGdFysEfN9F8DQ6OpcB0p4rsUV5jVAP/4hSBWcA5i83hpRqwJ7QsMcKxXAvMEsxF/i9nOgpid/MQIpfMtmzoT29yT3fLKijTDdn1Xnt1z6VkVEEgtoy1On/5bdl2XQhhxWx8Q53HydMxawnndr1VBFgsU858C4pbZXgo3D8sJFJ69Jd6gL/v3VA8jj0+6sBior169q6gsLgTVfu3HIG9rbp3+x7/Q0c8qdPDBnzSFbk4Y142CzHJuQwy0PLxbOt0POhDJiZ06ltB4Hiad396B5TghgPB0e1IxJbFZESsNn8A2wQQ+cYVSyLcpQNCn2eF6E59wJqLSLdRRvqNmnXKC4aEpKeVx8fY2SpY0FGDYZ10GBIzLY+WCiaWT3IcnfJZCV2nTPYzsm2iDMCicVneOCIAXjh6n1LF3v231gUcl20lVmccAtXKg06DAtTV2g2Pl/MI1b9U+lcO+Xmb2OS4foW6YFXKILoZ7WlW0A/z4XvaHk8GOugE7+gpsUozAsUXb4MAp6uRzLPSdDTa6ay + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_ID: AgB7VAh3NMzGACO2e0ZAr1RM9te3JoLg89dz7HKRVqUKosFQJOBmQG233/d+JXl39Kp2Ok8RK22mL8hwMH1R7WQo8E/Exyaw/4gmYLI2tEP+49c/3wwHDsSep9Ly0+35X8eBRrAa6bGog3Gl9an6dsIMqgtPYbRNKUUXa7AbDeEHtx6QTmA3cKnimZmB3b74ZShK27pNB5YX9gBj1RmPAA4djdUOICF187Coz1idC9zj4/Okv0sYUL16BQb33gEkq+gVB3yowxjorvxxVUeVGqBOoW00IHS4D1zDD7n72+SU4LiNqyJktB+5clwsXC+NMefHCybCSu+o3CDBThw93SJE1O6bBUatZ1ix0d/fJcJU1g+qw8R0MZPMKrb2WUeX+F6jMIkNz2URC8wTorr08YP4Sx1VGszFP+WaQhCfM5Rs8P7ocsYpwrc1+Uo0Z5cXNIia3YrZDufqhJUKfjO2deTqIMcHLLBsIvewB8DduOn3KtfX1uFh2lSW9VNPgyiPXfc5ay+hK/wwIfb2xDZP6NFeSnygDCzmiAii0SHyavN5f8ZJIG61j1ZxVoXyUiBBianlB6Pjhg8lrFCGo+UVIyOMprfvHo7+TRcXTQxJxd9Q8dQ95116ZhPvCo1wKvyTOSzh7wXgj1+DgmCiHMVsAu0FMSSGfAXWriKNJDuPije1CGf/NVVlfMs4rDiMo+BFgbas/d+NWL2g0bBxh1BRMZF6cgLg7R/N7OntkCC8IyJuiEfagDbOyAdKGbTC0RmE9PdkN8orbzaOpycbpDkH9RP7c20IS62Vwcs= + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_CLIENT_SECRET: AgAlbFxBCxka16j+gTGRp3meNib6yA5DV86mNeFlcLseyA8IbzD2MGJjHTH+dkl023C4HhRFrz4R1gZQ65tk6ij9VC4PY9Vw+/+rbIhNg/18pXcIrjlOQlzK/a2EBEgZNJIRjAspMHMaKuiVnT92JeN8QPShYT0dBXjPtt2TPZH+CfPBhWFHoHgekl2q4TTtjLAVj6Ylm89Ub9URZsAvxd6/cZgJd+gK1QOWfxSLQ3P/U9Ky9vBHYX99xEdtS7YdWty0dvPZZPWn/ZXZswaUliueLez5bnRldnBGtmTJEAvzqKO5yJsNZPRWI3I0HybjxrlKyRui+6atTlazf0rgRHIBiWAGVSmCj30CLBmx+LR4NKPJkQ6DOTe9MFmUUAwNXdH0FG2H8tdjLGE0g3I5T6lKhyY7DYxmC6mK9TfRiPqs61KEXYbp+qYdZrFsjEnyWArFdBE8leDJPciXseNmP7wfHCE9Ml6t6jBTBJEBPl5Kt0ZReXYpe84xkO8csPEQbcBOiDASunK8na4sw+helSkYH/PIWXIzRBcPO1uUq/YXxUVacO6TcAqhTWrJMjtAL/2VcY2BLcaYFATZ9Id3FVWk0ch8H2aGw1cIluts8d/jQsJlcNp3PeGKYf3eB36+TCZe6J2oP/vPsLANts4jap3j6IsbHG3PzG7lShprvuVEx+pDcMtfcQzE0vDtUXmIYht7RbuVKld7XGgwMTjKdBNFfU94o+9eRKuQYpD3BU6rLN9m4g== + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_REDIRECT_URL: AgBR2HY+qAfLKdIWo8C2cYXE+G/boUVoSTqhAx2ftxyEdFVwapT3PtYl0dyhKmUvVLm6YhexqB2n33YtVQLil6gBMEdVvGL1FANOtQOr3FIU3teg9j6zlBbYLstb20YiAEVcwpf7G4f7WY+ywiEBdWGJVMM6lj2WhqAgeYVJk1All5xECWDNGIm0IELJWRLzpG4G2zmp1wHvHwe8JEZokGnIsHsFSO+ys2idlTIBjUk4OAr53oG28MmuKCoyvELFhubhM+6shyugcvp+qlMovDy2b4jR3kHfX4i6DhHq5JZ9NYPyeen1mGlsX2ZPtuhO/d9ExnfFf0KvN7/GKt5n3g22lta/APFSDfNlYt4p7bIW16YBiOaL8Mkkk7qZ9pUTxkQW5eXrgQUlu0PbcIPjKw274noxc+Zdm7JEUTaKwfl3UEDMNRDlse7E4Z2wajehWE9RRhGp54mxPeNWv6P49O4T8YasWKx6OhpLcNroEyP60s/PvRuTt4hQ0AX0ybhCXaYyXTUyieWGYmLjI9MvdTuwBLqxM4dlxhp96PLYvVBeQ249Ve84U9+53bt3IpJ21cli2I5FNErr8NTiVGLyWakJxThauj36Um/xyIOs0FSPqxx7GaI7qY+JWiY8zAiAendbXRCetl9yc+FftvawICw7Me6fq/0SalNfi/AHv/L6h83xgeDa647IZ3uOx3kgEPoiEqvB6yNDImtU6AMwDD/UvqLVjjbDbGQo/V3bBZ7oYNMDdSmVex9yVYBGIA== + SPRING_SECURITY_OAUTH2_CLIENT_REGISTRATION_GOOGLE_SCOPE: AgBS5scIbyS12+OLJe5rBBvY+PV45cG+XmnHoOyxxqwXPYug4Y0FtbmErcO+pEUFLeJeX8HCqK2WCpr/e87900GDYOyNYiq1moou0Kdkaumg/8PTpm1owdBrIl+rjsuDkBvOIwAJf+dGMYXnAMQH2upesUNTuK4uO3gOhQWxsOh1kK+8J480uNxLHIavAYRKSO0r7aEKV/H7BdTED8g1yQc99s3xQS0U3xUXBCIr1/CaN3PLa75ASazVfkj0Jg/sRdM5ho6YaiJ41NmUhOp5T3FJMqV9Fc/3ckRIIZzql2Pu3EPRBFlLoxajvfhrKyyRupF4eeIwyJg3IMsC6y88s0orojhMpDimug8UpzqYbh59DtXcbbv+bMEjY5ZyGmLoqW1wghi2CfftSFuz9eTUD5yB3bN5Josg4oIfN4OgIzP1aaXn/O/vh758qC3CYmNK3CeSjvBDKGzw5n1PGGHfNSkxOZJjM+F8L241fAfZko9ZxQJgvm9RpgseRVlIj5t7MsmkD6Sei+DnR8I+5bWIfOYA2nJovYrSBMtbdA9pZubUZ7zMgbRuogcS+y7Lkyl5F8Z/MEp1d0RiXFe73l399zSr4jZMglfQcClxu0MuQh831Jjw7H5zzSfhe/gdTQqBej7J9Ct0pUHuXaNNALlMFCbGzvgp3yll1z5/MsMyUQpYSBBUnRbryAI/Dx5EEI/mtY4HLbi0ybZK/O4oDIzU + TMDB_API_KEY: AgBfAY6k854QT+IOHrRhBtaqLV8ZICSKqUUGR7rAPPuw0GMlgVG4Yg/9rOYUtKbH+N8tJlPGQt5FxdTFibp2SM6sVI1aVhs1+o5Xdp4fSYf8pJs++meIEMy+JkSQ+RGgpRaAZhmtf32BcwZJRnENWjE6Heso+C1N3WMdqcxgySr3lV0EXEw0cq1OYO9uD6VL1iSfapSDj5kbzrnt9rshpng5WRIL21lqftT8NuCrPjap0Je0XV1hbVBsFHtS869NxiB5L1zxz7arKfl+MoZ6mELZejPdODHukpL55nvD/Nt/zYrgsskZUUJZ6IyWrxPcGil5TzGvVe8niHVaSAGuzqR0R61kQ3B8eUo8CPqIeMXXb6m00u8q3l+2e5RHCzoufR6W8x9L5xf8L+TFyBZhNCCD09WsLWGQG/FmL4lrLRaXGkXklHCxp8kelRABcdExU1f4e1v5ipWa85VmhJy9NZ3pXmSjDTWhULTw+IzAVKlDou3+A3QtlWwAuBDD8wQlZmPoZVfDOoGojbCh8TPRbyyjBE5qdNDvVwktSCfmxrJt58NGVuj08P1ZxJ/98O3Vp7sSEgz0ZSgDfGJpYo48GXHWD+pexaiB3aM64ijS/V3dyRUlZjVqDouMC+bFQkOLOhjwLDv+7pJ9fmX0wyR0pOO6/THhpj9C0zRd8LbS/cC2MEUWTXF2WoDFeStHX81iMVxBguBVOEGGJ2teJgLYg84gkJxDltpvlvIXR429nXc/Fg== + template: + metadata: + creationTimestamp: null + name: mindscape-secret + namespace: app + type: Opaque diff --git a/argocd/manifests/msa/service/app-deployment.yaml b/argocd/manifests/msa/service/app-deployment.yaml new file mode 100644 index 0000000..9c72930 --- /dev/null +++ b/argocd/manifests/msa/service/app-deployment.yaml @@ -0,0 +1,30 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mindscape-service + namespace: app +spec: + # replicas: 1 + selector: + matchLabels: + app: mindscape-service + template: + metadata: + labels: + app: mindscape-service + spec: + containers: + - name: mindscape-service + image: 194722398200.dkr.ecr.ap-northeast-2.amazonaws.com/team1-mindscape-service:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: mindscape-secret + resources: + requests: + cpu: 300m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/argocd/manifests/msa/service/app-service.yaml b/argocd/manifests/msa/service/app-service.yaml new file mode 100644 index 0000000..4c0e300 --- /dev/null +++ b/argocd/manifests/msa/service/app-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mindscape-service + namespace: app +spec: + type: ClusterIP + selector: + app: mindscape-service + ports: + - port: 80 + targetPort: 8080 + protocol: TCP \ No newline at end of file diff --git a/argocd/manifests/msa/service/hpa.yaml b/argocd/manifests/msa/service/hpa.yaml new file mode 100644 index 0000000..076d176 --- /dev/null +++ b/argocd/manifests/msa/service/hpa.yaml @@ -0,0 +1,32 @@ +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: mindscape-service +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: mindscape-service + minReplicas: 1 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + # 앱 배포 초반 spike 방지용 + behavior: + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 100 + periodSeconds: 15 + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 100 + periodSeconds: 15 diff --git a/argocd/manifests/msa/service/kustomization.yaml b/argocd/manifests/msa/service/kustomization.yaml new file mode 100644 index 0000000..e1cdf94 --- /dev/null +++ b/argocd/manifests/msa/service/kustomization.yaml @@ -0,0 +1,4 @@ +resources: + - app-deployment.yaml + - app-service.yaml + - hpa.yaml \ No newline at end of file diff --git a/manifest/.DS_Store b/manifest/.DS_Store new file mode 100644 index 0000000..2828553 Binary files /dev/null and b/manifest/.DS_Store differ diff --git a/manifest/monitoring/a.txt b/manifest/monitoring/a.txt new file mode 100644 index 0000000..e69de29 diff --git a/manifest/msa/auth/app-deployment.yaml b/manifest/msa/auth/app-deployment.yaml new file mode 100644 index 0000000..b344ada --- /dev/null +++ b/manifest/msa/auth/app-deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mindscape-auth + namespace: app +spec: + replicas: 1 + selector: + matchLabels: + app: mindscape-auth + template: + metadata: + labels: + app: mindscape-auth + spec: + containers: + - name: mindscape-auth + image: 727646470302.dkr.ecr.ap-northeast-2.amazonaws.com/team1-mindscape-auth:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: mindscape-secret \ No newline at end of file diff --git a/manifest/msa/auth/app-service.yaml b/manifest/msa/auth/app-service.yaml new file mode 100644 index 0000000..ebf44a5 --- /dev/null +++ b/manifest/msa/auth/app-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mindscape-auth + namespace: app +spec: + type: ClusterIP + selector: + app: mindscape-auth + ports: + - port: 80 + targetPort: 8080 + protocol: TCP \ No newline at end of file diff --git a/manifest/msa/auth/kustomization.yaml b/manifest/msa/auth/kustomization.yaml new file mode 100644 index 0000000..97448eb --- /dev/null +++ b/manifest/msa/auth/kustomization.yaml @@ -0,0 +1,3 @@ +resources: + - app-deployment.yaml + - app-service.yaml \ No newline at end of file diff --git a/manifest/msa/info/app-deployment.yaml b/manifest/msa/info/app-deployment.yaml new file mode 100644 index 0000000..c3d0b08 --- /dev/null +++ b/manifest/msa/info/app-deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mindscape-info + namespace: app +spec: + replicas: 1 + selector: + matchLabels: + app: mindscape-info + template: + metadata: + labels: + app: mindscape-info + spec: + containers: + - name: mindscape-info + image: 727646470302.dkr.ecr.ap-northeast-2.amazonaws.com/team1-mindscape-info:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: mindscape-secret \ No newline at end of file diff --git a/manifest/msa/info/app-service.yaml b/manifest/msa/info/app-service.yaml new file mode 100644 index 0000000..0de7534 --- /dev/null +++ b/manifest/msa/info/app-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mindscape-info + namespace: app +spec: + type: ClusterIP + selector: + app: mindscape-info + ports: + - port: 80 + targetPort: 8080 + protocol: TCP \ No newline at end of file diff --git a/manifest/msa/info/kustomization.yaml b/manifest/msa/info/kustomization.yaml new file mode 100644 index 0000000..97448eb --- /dev/null +++ b/manifest/msa/info/kustomization.yaml @@ -0,0 +1,3 @@ +resources: + - app-deployment.yaml + - app-service.yaml \ No newline at end of file diff --git a/manifest/msa/kustomization.yaml b/manifest/msa/kustomization.yaml new file mode 100644 index 0000000..35e3d97 --- /dev/null +++ b/manifest/msa/kustomization.yaml @@ -0,0 +1,5 @@ +resources: + - auth/ + - info/ + - service/ + - sealed-secret.yaml \ No newline at end of file diff --git a/manifest/msa/sealed-secret-add.yaml b/manifest/msa/sealed-secret-add.yaml new file mode 100644 index 0000000..23fbccb --- /dev/null +++ b/manifest/msa/sealed-secret-add.yaml @@ -0,0 +1,9 @@ +apiVersion: v1 +kind: Secret +metadata: + name: mindscape-secret + namespace: app +type: Opaque +stringData: + SPRING_DATASOURCE_URL: REPLACE_DB_URL + SPRING_REDIS_HOST: REPLACE_REDIS_HOST \ No newline at end of file diff --git a/manifest/msa/sealed-secret.yaml b/manifest/msa/sealed-secret.yaml new file mode 100644 index 0000000..76600ef --- /dev/null +++ b/manifest/msa/sealed-secret.yaml @@ -0,0 +1,30 @@ +--- +apiVersion: bitnami.com/v1alpha1 +kind: SealedSecret +metadata: + creationTimestamp: null + name: mindscape-secret + namespace: app +spec: + encryptedData: + GEMINI_API_KEY: AgAweotXAYurcNF2aK5pUL+d7EKW9BVbnr/BK6CVMvbLqbiiDc9LvaWUtBeLIZDbyBhYXYval0FCLfee1tFPCWcyqCYG/Xk87LYN9mrA9DaeD/duWYJnlV95YnDrECXkJcdwu5PJKLDpE8RFkoBS7ajdndCwNti3jGo3an+2O2xZwHHcCoWF43eQx/jvIflRuq9frj0/6E/WVOm2tCI9olZxlF6LBQYYcTZ3OAV12ITm9SjkEq9gAxIvexM96T2IeZRT0aurOVi0bwaNiy3K32BUhcWexFQ7E6OFEdEIVPp0tSGaitShnqVYPuwcUgGLISycbquHnWuVn+DLU1XTb/4WxQq/pJVXok1SGA+6WctOYkT9THTUTQveGLHdxVMfTLwzOFxDyeOwcg1QXRy0VQNxMa6iBj5UTmRzRfwHq/e9UQEWiQh1ygBKTUEm+VpQvEbIkayVxmwzQAWMT0J2Hd+QKpzBRqe7y8ddwP31qhtkGVvWJD++bUlS8rBQGjVROjvzQJ4V3sd69wn8B6mEQNZJDGK/l5JPX3IraCD/MqpN0op66/OHPfJo10m9BV7pMyExzFQ/4ByoCGfdKvWEQtLfcYiXgdRoiRa3C1DldxiyWr7OwYFhsW6EuuMAkP5rQ2WY6smHv0o/zR2MPZIEFOtp3kXfn/74n4cpY9zO27mMJRWSRMeZh8hmUO1kpnWhdY5u3Lu6H4oQ+sDgAoA0NU6vFN5fUgiuFyd8YVpbB4TuHUJUw2tVpGk= + GEMINI_MODEL_NAME: AgBnSUqNn4kOC4LCOSNHfgqufnvrMTO8onOFY9AYWNwRv+ASQYq4J0YLFG3OMuAcBz/il2j6acBR/DSQB2Wa6a6lOqu3uREXQqDI3/tLMBUAbDjxZGUwLXrahOocscUpllD+eJEMmOnGI3HNtcSnPIzX5DX5CNlmNVlOoayBUIhLgNQb/uz2ACJTtVC6zMze6HoDhJzLtX8cQ2kvU/ut4kG9IUiMfRdGeftrxPWOcbutnOfBOzPwCi5ooxKOLI043cXi150Ztkisfy0iqqz7+j9ZC6ZLgPutOedJLJxYQ3pWdYjdYzt0/s/Oulrh3sMoXXMy8PE2MRoor8x8i4pb7yxEYNMFqSyRU4SvMuJNc0kRadaAjSfn0qhGa7gbW1jvzVc03YxbYHTnhyP8p2Ap1U+acECBVfmdt7JVCeY9K5hS6Hsqjea4U5gzy8GQ02V/vqzOuYoywMxN87FwedFgXjP7Feg+c1OiD1DfxpcWnvaiMorwdv4u1rrm1DobkdjJDZPypDqebkNfavbzq8Z+Rcj+OpG577cHyK3ruQutDuR4ctBtQP289JMyNLfNJ+lq38vlyyQomFHrc7UbxRsUXlbxyRvvqbjT53urPIQf9liYGG3o4sYStE7G77lLBqanRe+2MHGb/r8MqBlccMFqPTtNDRRVKghabDaeJAGXOG+relLI9Apfq/p7AOgRYVsAOCxYJlP4CZH3DnEpZb5PWN2PhxzStdq9mkrjKk3PjgMM/iqEVw== + GOOGLE_CLIENT_ID: AgBV/c0uj2NXls5mCEq9Cp89rvrzP21CpP9Jl88fTBL81DASw4EIUdjVceYUOz5ALgkDbXfKFS8pS7fW0WcRIa45OkljtzrTKf+2U6z2SBA+DeIIeBEIM83VWO4rzzPle4H+kW5iZrRTKQhq6Dgp3gBSE1HUDqufNVjuNDRQnVG2qZ/O3GRgdcOJAn06Keqr7xiZ/xb/+bvdG0IUbIhhYMka0bHcqhd31q+TwuVIgEmD7khCfQrmBccLhM3vqjOR6JNmJLwDYQoAJttVuUZRLB5EXjwTrT28qPPnTuacgs5+XryPfzJ3t+T6k77ceFn3obSYlqiEQzNYDnGnlwHpkSRAbGg/Sm4Msi2C0w6YtD2BrY2nV3aMcdSHhGX3JZ1XrTeHddwCBQBEGDp0cZetGEH10U7nJMzG4TfEiFuYfljXhNVbcMX7lMhJnTFIhxj3Suu1ayiXeIGk7iIozE7nCYRzfMskWeA+Gxi2UnWOcAwdnhdbK0oWmWx7aVGdiyhYPiL2ZyZw/09KZMBN09RENNepYzEmNJUln6ZUfl2dQUev8DqoAUNWoiAai/JZCXkpm/iOrSkRbz5CTBS/UrH0NC6kOU0Mkyrfe/7baVAor0MzHckGfG5qGPFky4Kt+jo64QUSXpYdY4Bt5xJlLAo/zzFLtJ5FW/CBQeYN1bbFgDdxrGaooGVL5dBjVzUtA/EtdghClecJTKPt7dwOUPgWx6DdNpbyCAEhB4IeS76JL8NhcQ1E1dFGRqXGabJGTy5Y2GusfWrGaV7Bg421yfzvAuG9WM5kdd2uj+0= + GOOGLE_CLIENT_SECRET: AgCXz6BBUN+fnKPMtiPylkLOtnlQBddlnIj/2M4M96ZPO1w2HEsWJGVoZ9VxA5yBP0qRBIkoz/wprm+AEECheTUA0FKvEVS30RYGOHjEGcpo9eIZSJdWC4nk5cUTi0a+s7enf3/5SM9e2e9B3bKrSclTwdEUE+k5itms7/kjj9QBqPSL6RWsr5epXfv9nKOjlFtJUzb7Dpg/J5nz11/YzEWzsgohFXWPYKsOVZBPcoD8dXQ3NqbKHE7tEubvHk0njLpCqeBN7Fw0BbfjgpjOsrKvjB753ITv+Xaeg4/ulROlYz0x1wF0k/wRx2VDZSJ6briTsFGUut9kecvsRwIGnpS2PJXNqXuS8z5EBOc6b8pIg83ktkMkLnOcGm9PQ5KCzxX8mtm0Wd0+tfs2Z0Esi9M9QHMqcxu5TCPmfy/5yiYfT53souryVcV8DiM/sY43cp0HIQtP1oTNB2ryKq9/nPUB/RYYNM2LXPGU/07NcdWuwR8maO5k3WCHrR6mzH84AwDbClq6vjJOJibw1UHQvftEX/c2Q3OpBlscG5qDVAqJ3YLP/O0nCtANt4v50ArG6NNVYK5YxO9kg9G023CPFEGIHdrC1QxWi0+/aKcMLGEn8Ch3Cm65kLrNdCfEKu2IP5uL05mZGjsbjSbETTzhyTH/0e0kDeDdgQ//Yt52lQ1Gyho6UVrgQ7S/drJCHDMKRryfyFwT3mxfibtz1U9tkUnDsiAgQG1g+1rt2HBUTGfQQdSDsQ== + JWT_ACCESS_TOKEN_EXPIRATION: AgAcmHwZejQ4m1IHmJuYkQaXglep4rBw8ffqWQp0fgGnmB0fCpSNjX1M3+NMrVbZfZKMQYrAXHXOvZ5LkHD2KRZl815Uri/kTiKxTZqK8D1RpvTFD+AoSVYQHD9jM9UDOM2OrJaJbaB46+KpL7lGR/jguneNWMLlIbxf2oEFWdvFFsxDj1ZejHzuuY9FytqYzCWBahSA5jVyTTEVatyYStO5paRp91omrwvldBjhbZfVY9ZMNVhP3ISJFuGNQpcY8gHAbKYoELyxjq5rG4LYtpbTL16XFUwbJ/krBq4RmifkMojkFoHTkGCG2SJdHdrIg969dgDaMdjaucSLKRM4Bv6KQzQsuKfNHmvUSY+kILM+HHRlNm7w14o2v9q+ICdbcOAoVvtYtK+Ty922q2wc2i9+ZmXD1j0FWfWRLEM9RFqKBPrup8kIwLVHYxVE8W6Mk7HIIAeA/Sy7Jd5/QFS/WuqF4jvfstMGd/XkTWiwhrxlJuO2jlgbK7Ekijh8NVf2i9hvh9WFO6ndUBJFCxTCbkCp5mnf3HsVrK7ywyjWOYYJWQtDdN/q/cwHoy5pOig7a9Y6PDYH1/kAkorIVgjwoRKot+kx9PL4MGAFKw8MjHVnYPO5gILGZOnyIaBvkBE4cM310mmaJyq/raxkGGPsGO1stBE5CQ6nCg6TWzmfgtWuXVacWPeqYEfdT2gd8RhGfkAwHl3zPYsj + JWT_REFRESH_TOKEN_EXPIRATION: AgAmZHjpZjNGTJE/WPgz8d+EvcXciybJn9K3LQAMvc6I7Huaody/lUU3UwkLFgZKiu556YR8+0vnvME9VMjuBwH1K/RgKT6qNaIxyVESdHIHPMvmczsCS+wS4b3yzolyOMrmLHo9DJKIHu8r5F+c/7nlq3+MiTp+uICgK78BVbzfgJupxoh44U+AWGE5lohLMmzWGCiztOYdv/EnkkMxw2Mq44RUGVvp3HxyHKVcvqF0KBCCzU5bAIH9cb3O8q2wDH7XX6PzeHUj+Q8fhrjl8Q4RPJINakCG7nISAhd7BfSEyax6iiu07kcmdRAR2wdqtHAtROVxOnTeOcxonCrPKlDjUdRtOyVWR6CvoSIyq/6lUPR2S0NBN5egfSjWdAwwrcndGXd0wBx9tApPfwOLygQRba6C/3KqdmcjTyMePz1fC8bwK7qsIaM0SVPKV7En+UfUeuQ1Akqp1zUNxL1eCdqUjS1KcHhJpNwjjoCk7EEnZd3aWGhr5TkHS4csXf/kx4YMXEqGkgnMeQHYCgnF307+dTWGVF2Wj2CyHEtmzV1ZtRdqPb5KSMW8vbDiliAkR8xmBCKedE8OebDrj8jl5HiyQjoilPxwyCLHL+eRAc6fJAF8DdKLbWTTdXY+njzLjXWSEkG/2/JItW/t/Lmfhdzhzb1gMjfsQjtpNCDieO0vEXWRXm5F+9APbtJCVD4uMcE7vVZ6Xj8pdx34 + JWT_SECRET: AgC/lr9bzH0fQZ9ZYL2EmUgwZRwsaHgfD/Nxr57WUVnO0BHwCPcrWUuDwXCFLMtSuThYrzcLCQtJm684BeBXJSSKY2gbBEbSL+j9SVINSr6vxxpRBDWX6mc+VpAztxufAIOVEM39ZGZMG7MEAJ2I4Qyam7e3/zG1VZwyLIIbYVtz3MNHM//p+cIaSAX/cWKpaSYyTXIarCDqZm5NPSVpAXGIMC6a85gsVmyiWXyS85Bz3TpnDJ6f/mlqua9pioHn4OCfpcNRxPLjiEe8SUHvMWJm4M/7xurXM6jznGz0rPTaXU3QXtTgLUkpJ59UVz9bQmRC8cIDeA6mcGC899/SsjYL1Vw4+2twuM6a/qb6HMQNMd/8qW521Sz5y8nPMo0frbyGQidXotPjbTQ11PhC+NwEReKjMzJMfuhP3LXldNsOc4n03nTXjYQOuVG7zVzZ+9631O+PEGTJ8dck8B0CNc4I38fiFr/FQmIaNqzAQDRsUvG3DeO/G56p3tdIlLNfanXUM1bxH//gqJEgUMLJLI7QxTGUSsHx2OXUR2yinZiP8G7dOCHQr/mWPOMYboE9EVwZBAELNs7q/9nVtxz1a8mypZkaFf4ji4Nb1T3/rAVF+xzFhTqLYQlNLkXSgAiOA3qmDEkTcr7YqGjSCYpCajutkM/GwPhRryA2n56qcxTeGD0/1APMtHGb/nlzw/m3UT+MKTBjiv0VR+h9T7YeVh2MEqMgHH9cngycAutsX0cITEPLBm5JCdxHcz06vN1KazVuHYbd4vxLjR29vw== + KAKAOBOOKS_API_KEY: AgBmoQ6rt5wZRhrqykmWmYTYkY1w4LbEDb20HpFXQXujTf6HWuQopeNTFSnWJLCoP2hX4fEB5oInPxly+vveENU9CL8Va3sMQ83iug4j7LW8ELfTR77o2elUfM4IPFltol0mitVLdgWDVj11WpnWoW4lO1JzXADwnNBSZjSy9t0TaNq8wTG/UKOTByKep2qqem4NrZ3jL9OHUWaeMAVXK6Do8I6T3pJAFyQXpWPsIq0zWXprkaqkZNYcVbs8KfmflAaOvyL8Pp0gnMeFV9x9k5Bc6aLmX+NAa64PtPrUARtO6aEkVTZSx7aS352jvLBURoAkGWcmgxiE0ykD0A8DKgEee77v7fCyUizSk3Uf7hXgACgMWlSaEVNgNcUW+5HIIRu+NAUxp+IwONhuZmhJfKctFfzKKg670lWh43JPkNHvjSXe/t9GHuoOtg4A2eX2ST7e/2QqaaK5RE94yxigyJNTic8MDPdxbK6Dp5TEeNAmbpc3rvIVmit7whTDFM9KAxnlmaN4FtO2s6XPUZeJrGh9FTETYuoGHxakLj71T4oAU0Lr+fRrNJ59YJpvrO69M7S51Y3L5PUzUDaFuuKnhPB1W/RsOpJIJe9xG+9e1mddenkS/RlKDfy0ovK8/G1Db0gqqpOmiRz/qbGUC7EEIGzAYen4+77V3ZJHhjCqzqK+DLH0e+PXwghRnqYapJ5lrG/gr+gL2k2pmjEthvVwDobucco0CdJpUtXDjuTjXu1DMg== + LASTFM_API_KEY: AgCSfC220VgPsPY50L8TRg+6zJqnHGjHbyH2n650GJCW5/PEvbK4UsTM74qx3+JWPfW37ESRIVGxZEAcg4IwW4K4tQ7rQFWmmcDQ1jfcRZsr8UxHge47VbjEDYrUBi1DCwL6iMQOKLHIOPTjchcjRSaiXWwjJZI/G2odOxxb3Ibxyl5U/p0paeWP3/avTkifKFFzoctFS8qRZWe7nJudNLJs7KSDNY9yUhlZzN+syTilVKJWe7ZwYUb/sg2G6QhIEneslPa+wYaIHsk4KTeV12T9duH2ZNFcz+uMyj/oVV7kBXIW/AYULJlrjzMLammETsrTjbjpt6Hmg8Sqkb6POe6vGoZ9eN5dqlQOY6v8l7kcARekBGMy4no5z+38B2KptfPT0ap37X5qNklgLXyiFzqyoeXgQF8YKR4Hse/d0lIOODrZ/92IcnwIHTYSH0DkCbRVGS6GHi6WUeMlz48GYfgw/l1hj+91JHto7ctwiWKN9eKfn9+/TqE1LL1UBdZ5d4aCnDsGdnpSLkxv048w2PhZCxMssj3f1Unukx9QlUoIwFT7omhPL05bFKlep9KRtGA5OKmSZ4WN02pIQv9tCdNoQ+xAK83RpgbgZ57ZJcMKhxont5lehhIzAvqWh3j45yLCXSp4qJcHVDxk3KsL1UxEz2ubAXbCbhTy/5Qc2Iv2bL9z4gsFnYHIQm93g2b7bZ53Tun1x2wHZZPUExaiZGKnRFBLwzCR2zB7pjMtPSBPpQ== + SPRING_DATASOURCE_PASSWORD: AgAxYuOOdSji7anSUesGffCAiInR4jARO9uwkwyLj5hbHw5S6kIeZxwtYpHytYHovzDxd2XTJqzqa9FAK6UAuyCBNG/H1+aG2GZ2hDQN516wO02DBYTBmNCvR9MoeRyLRaOWvqgaWOik+ogGJ1SlrEu8kd0fSIXOQk64gs4dqZTlcgjPaHYtnPQ2/Yim8hQiXd5ziGTV72YYfHmNDHKvHpmj8bV9jKydAZqFq/0gi2Hzs724Bmahy3fAq4cwEppYB2MCKnfq7LA5HG6pT1uwD27NxwjwESwfYUaFcW64FjhmlSqnSD51HbwAoK/p5vSixO5DKPfZOTrZViJyMPuZr4Fid4uNW+14DDloezVaBzKgEa1fTpWr1p4zxWlL4VJB9F8ucowpm9Owjzp1BBpOIvGEm/K9vIw1Qqqo6dDVk7VmF0z0mb5TQjrFTQGt8ejohTWKh2f187riVvKv9yFbo9uKpVFP2Qt4Dak5F4+/Lg6Py1uZInuu/mqWaPlhDpfsqMo6X1C/i48wmchkM6GiqWhmQvcUhu3c1zvEfg7jhhVK/4Ld4WdMIL+zEB6Ke5RpDaiRxFZlFNoDrBaxDlioj4RmDsSz8T8aTBGM8Qgr4EA7wIUnQRcSDKBHTVSqWyuah5tYUO2zhYvKfX3UxTRbMSLcrQhqYs2XG6xzK4DulFGIThV0Qs9Gh2K+9HPhG8nfrfM2yeeJWWKqUQ== + SPRING_DATASOURCE_URL: AgApIFFdHyE8r+fmNQ7vJt39M4wBDr+xx6k3ILz7XWmE2RUhHwTM3Yb1T3uZ9MnoiuNqB0+S3y2TNuJnyh0c5A7PPkOXerdpJUCFCpAeoHBHCp3RyViV4O41lR0AoPGNGsWOBL/Nbk/9oim/Kuxij2ttCB7KV+GHi7CKrcyPPAsdyiYQ168lUAh0cHlA63n7BpIEBwSKT6ivF/L0+E7G6yrdFUx7p7vyv/FqW3P3fy4ErFvjhGL70vZLte2lbun7b9d+kfLIveIwrxuwC4lYrGwptFElsfPpdsvuKqHPPlR4TtxBdIi4mVTweUCP/Xo257x9MmF/qJJaasUBYXrTqOseVcCYAMJtgzkmlA/dp8+VQiFG/bzjBknCEADghgNJW9qSaeXFxuWiF1jSBGgcwNbTciKnmcLxMfmICHF6sgK78wPEhA/ni3kaI84yj72h3HdsJZzo2UILyG755U9YHGrFSF47+P+tDaYyef8U7gAVZ3CnAWxQWvftQR0akA1ERmzgJQX/W8SGEGSoFDX6Lc0lsdeJJ1m3heeEasuONoDKn6aA+9HvYhGCHp9mEO6PAR9w8vikfMWLZ99T5f/Rq/0GItXQSArzz4ddJ8LygVs0XUC8g3lkLq1fDfNpha+xq2XZgBrqKfOEIqj+T6Y7LsbW0YwgH71/Ioyc2RUrUkd2B1MHzI1Kq6IpaPcDCBYm+LHijcVk0QKJPiFydd5IY3UD3TB5j8k1SMlTkKWOf9XpptXZwMAkkRoVrkSnIcJfyOMFrKxd/czBp1i2vb/0F1Z9IjHX/L2Wjeajigi77gfCz5V01U0lZJqftFYSkgFeH9KeMBQqQg9krF/XC93l4tybEoiu+rGnuCs= + SPRING_DATASOURCE_USERNAME: AgAahwAYZH0klBPHM+d/5LTcSHmuNNZrql4qQadjH9z3nqZ3xQz7Par9IipkuP5+/VS3PBnexLRzKD+Tpavp0d4tX7I6c4sJp87asCBfjN+BHZfm8PYSlbxA+4GH/UO4gzLrFtP6PIkFBQA4BSp4NkEHuE+y3RaRCY3Tc9HmL/oQoVe54CXPHzJPStqoaWjljsJhuDiXU7bN1u+YDjzkmfHoxSdxSSx9fIfmm0sheSAh6VmLhWqXkkGt/RupRmMdSWcYRjVEMo6o9JGZC8iyKuK2u0Ody2pZBBER77ZhpiQn9+J6gIvOovzHKjieICWCIrdmWN7oyG0ujEyEVpLSyBdOWKdvmXN/6TJlqlcHU6uIjxB0jGVuKuhu/3xT0G6sxGyi+eMKNMVsP/kaSFr239PihWTqhTdkNY23tG9qrDM2SiGzYWk+jqM7DDD7it3RClfIsF9EKSah2LsShsfaNNhpqI4KFwpmSzQNFYJhG6lTxf0KPF6t5B3Swi+b9knTCZ0XeYLASKE0Rg1+H5WleJUf9Qgin6ZHCchlINuEfsznr6nwEVc6lOWAChAWBu6tKX4+Q0L/V+VwwqGSeESRei4a57a2i330AY76SRagA60UF4EnB5a5h9XVoIiLev/M6oPacCwyh1gZCuU0lWAXzR6bq7HN0eWak2MfiG80yehYRq8/Kh43m1M5hlC3hobdZxM4JRaw + SPRING_REDIS_HOST: AgAJuuuddj9HoM8HPEkZ2dJlICnI1pvzfK9jQMx14L3c1W8tf+lGvnGbV7l29rU7rJUOEiTE8+NA+8YfOw3saTTK3MJQAk2uu4t1F9gII6qCLXH1FNxm+1UesGZAZD4EuaJlgZwSdnRZYO20/fDP05S44lK5fcg+PU1EImacYECnAE8hEXzPpGmThzlQ/zDizp630UiQAUHQHXIim5aFG38Eo4eFxwJOewuOQWIS5EbB/28ytQ1cLHQQw8NrdzgQXUIN3X4Dc1B1uRR/41z/07TnG37zfcBVdWUfjpzSpOe3TgBqZGXvzZLynkVEHVOdeHnBHvwL5a1woVZVI/uXZ9s+DbgLLkNGwNhLOSgMGpW5EOIxDBLZ5DW4X7nn0PV+jgTTS7hKOdUbloQPQRT7e3QrOSFO/kEEsRcKUmtiAnbaA5OpZvqdvArHgazMVoalzoTGzP0BJFbNO5o7MnjvkNBqPiHvW9umlMfUpH4LcIuvw61OM5hnWwZ61w6n+OgU4BeaJmbdwQdanTM8Kt6hl+TXezKRFLjxGCdJvhSXYT+Yjobj7phh9RQYIN2yl6Gz69SZnBIl19lH00fYdd6ZKZ0ENHvQNUN+OdqZGTScJwt9fkiB7tXVyL5+V3/toXALp+WHj+QiH1v+5X/MztyIU00LJC2CgMreMEOslvnDJ8RLevSa/VBTAyUt1R2pdUjQnF8gbvz7tJBl6FVr3zqwyzaOow0Tgnd7PydWlDv77DY7z46o7RldWz82PP0CVsAr+t0= + SPRING_REDIS_PORT: AgBDshqbORSfAx0g25jGGGAUbyySO+IJEm5l3txqGhDxVdG0lBmTb0/Y7kkiAD4SLfnT33a6LvBf0C6Buo+jVjkPfQMsziRLf/03uvOUzRrpOMKYO4GbByi6ORdPGDE+swUwF9kNIUCG6sktu3K0qypn707uOXK/VvCIcLqfGc2ekBKPdAS9dkHd8Aa2qmTp1iIZBS48pDQhu7L5FmENWWpDkHEfW+pTkAXCnqkOOL70aNgar97f70EFB2bjCQaa1l61w4qUMO6l8jLSqu+f0ttr9Ux70Www8Dsb9b6bNm/HPP9VDzyYmrNSzoW8OG7N1Bnm61W9wVTerSXHbyXR+Dwt/vYmuLL6e7fa+PUt6xr/Ntxeg5ehr+VBNzWyNzevSohaJjkYU+z2wbXpWLKc8Ja5xLGhriluJPmfgBykTCokdOjYoutE6Wl/m6evSxF+IsSKmh7LrUJN0djQUfAf9nPFpVkLZ+Kn8GpkMoe4NKJ1ljjrK0Lq34hSireg+VxFjKIu9398Lr+4IRWUASJtMzDb51JrsZnE+uO9lXQrq0Xu8yJB0uhimEnGQ2f52WIPzWi4kQRKL36xYEtHEm6Zr89fsVGF7WevxtBLOwfl+WIaUM6uHOek4X29gyDfWEhfIz7qRrk9GygRvw/PnGqg8nAWEbUARKSqAE8S8MSAMyWQYL0hM1VRNJx6a/trQ58MdBk9HfHp + TMDB_API_KEY: AgCidkV044iXOr62n/wizzIaRGIGnqbDIvjJ58opMMf0XtbZDi3arQb7NX7KQDyZCgLbte2YRm3dxVY7uqZB6Fr3g6T5XTd7Kftkj8Pv4FwY+sDwLRFOA/9H3ubF1CfoMgMEE/VnhEpKAYFsYo2CiuHJGV+zognFSQwZBEc+mWS8klN39zGI28DFMJQNfpQAvr/3IuY5cp+61LK+F2ypS8yMXZrEyOFefp8Qzk+s7uOgRJV9N3W46Xypy6G3V9DCDNDyijeQ+Jd+gb9jbIW0uy5SJlgUS3w5iCwaVrNtlHvydItnKM+UuIcwD68EUEawlVPZmTtDvsLcB62mMZuNzOvM63rX53AvH/QCBDTZDiAoQxXdU50iBzMUg++jLaUQVRk5wQX+Fa0htVxycTD7Qb74hKwVhH4jeke0WBap2+dsRf9Hht23eve5ORJwvjuVGJZpY9ACRejNDDvLXA9k87r3oNdHjpzO121X7dK6ZjS9DTgk6tMaPbwy0PeF/HvL0B/F08walnq4GxTWHO3GoniO1PoUU4zQSFoN65cdI+ZoMUU5SdI820kxSD4Jt8jpMLVCDiPIzQrjCuxR29oCyd5hmnV8NiSfeoqHWARTBZChaiB6W2B1HPGxXUgFrqfgdeIVOfvL2pd6iZR9o9vux1/9GMHHw81NzePUQ5Lloq5XjGKR5JPNV2U2Xh+n6Dtzrhp/r4e50ACa7f1fFglH9bLeRr9em97dc+IxqqzmLcpvbA== + template: + metadata: + creationTimestamp: null + name: mindscape-secret + namespace: app + type: Opaque \ No newline at end of file diff --git a/manifest/msa/service/app-deployment.yaml b/manifest/msa/service/app-deployment.yaml new file mode 100644 index 0000000..d28defc --- /dev/null +++ b/manifest/msa/service/app-deployment.yaml @@ -0,0 +1,23 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mindscape-service + namespace: app +spec: + replicas: 1 + selector: + matchLabels: + app: mindscape-service + template: + metadata: + labels: + app: mindscape-service + spec: + containers: + - name: mindscape-service + image: 727646470302.dkr.ecr.ap-northeast-2.amazonaws.com/team1-mindscape-service:latest + ports: + - containerPort: 8080 + envFrom: + - secretRef: + name: mindscape-secret \ No newline at end of file diff --git a/manifest/msa/service/app-service.yaml b/manifest/msa/service/app-service.yaml new file mode 100644 index 0000000..4c0e300 --- /dev/null +++ b/manifest/msa/service/app-service.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: Service +metadata: + name: mindscape-service + namespace: app +spec: + type: ClusterIP + selector: + app: mindscape-service + ports: + - port: 80 + targetPort: 8080 + protocol: TCP \ No newline at end of file diff --git a/manifest/msa/service/kustomization.yaml b/manifest/msa/service/kustomization.yaml new file mode 100644 index 0000000..97448eb --- /dev/null +++ b/manifest/msa/service/kustomization.yaml @@ -0,0 +1,3 @@ +resources: + - app-deployment.yaml + - app-service.yaml \ No newline at end of file diff --git a/mindscape-auth/Dockerfile b/mindscape-auth/Dockerfile new file mode 100644 index 0000000..b71999c --- /dev/null +++ b/mindscape-auth/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17 +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar", "/app.jar"] \ No newline at end of file diff --git a/mindscape-auth/pom.xml b/mindscape-auth/pom.xml index 27bf349..99d7f3f 100644 --- a/mindscape-auth/pom.xml +++ b/mindscape-auth/pom.xml @@ -44,13 +44,46 @@ org.projectlombok lombok - true + 1.18.34 + provided org.springframework.boot spring-boot-starter-test test + + com.auth0 + java-jwt + 4.4.0 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.mysql + mysql-connector-j + runtime + + + org.thymeleaf.extras + thymeleaf-extras-springsecurity6 + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-data-redis + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + @@ -63,6 +96,7 @@ org.projectlombok lombok + 1.18.34 diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/controller/AuthController.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/controller/AuthController.java new file mode 100644 index 0000000..991229b --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/controller/AuthController.java @@ -0,0 +1,97 @@ +package likelion.team1.mindscape.controller; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import likelion.team1.mindscape.dto.UserResponseDTO; +import likelion.team1.mindscape.entity.User; +import likelion.team1.mindscape.repository.UserRepository; +import likelion.team1.mindscape.security.jwt.JwtProperties; +import likelion.team1.mindscape.service.PrincipalDetails; +import likelion.team1.mindscape.service.RedisRefreshTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.*; + + +@ResponseBody +@Controller +@RequestMapping("/auth") +@RequiredArgsConstructor +public class AuthController { + + private final UserRepository userRepository; + private final PasswordEncoder bCryptPasswordEncoder; + private final JwtProperties jwtProperties; + private final RedisRefreshTokenService redisRefreshTokenService; + + //사용자 인증 검토 + @GetMapping("/me") + public ResponseEntity info(@AuthenticationPrincipal PrincipalDetails principalDetails) { + + if (principalDetails == null) { + return ResponseEntity.status(204).build(); + } + + User user = userRepository.findByAccountId(principalDetails.getAccountId()); + + return ResponseEntity.ok(UserResponseDTO.from(user)); + + } + + //회원가입 + @PostMapping("/join") + public ResponseEntity join(@RequestBody User User){ + + try { + User.setPassword(bCryptPasswordEncoder.encode(User.getPassword())); + userRepository.save(User); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.NOT_ACCEPTABLE).build(); + } + + return ResponseEntity.ok(HttpStatus.CREATED); + } + + //아아디 중복확인 + @GetMapping("/duplicate") + public ResponseEntity duplicate(@RequestParam String accountId){ + + User user = userRepository.findByAccountId(accountId); + + if(user != null){ + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + + return ResponseEntity.ok(HttpStatus.NOT_ACCEPTABLE); + } + + + //로그아웃 + @PostMapping("/logout") + public ResponseEntity logout(@AuthenticationPrincipal PrincipalDetails principalDetails, + HttpServletResponse response) { + + // 로그아웃 시. 리프레시 토큰 삭제 + if(principalDetails != null){ + redisRefreshTokenService.deleteRefreshToken(principalDetails.getAccountId()); + } + + // AccessToken 만료 처리 + String expiredAccessToken = "AccessToken=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=None"; + + // RefreshToken 만료 처리 + String expiredRefreshToken = "RefreshToken=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=None"; + + // 응답 헤더로 만료 쿠키 추가 + response.addHeader("Set-Cookie", expiredAccessToken); + response.addHeader("Set-Cookie", expiredRefreshToken); + + + return ResponseEntity.ok(HttpStatus.OK); + } + +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/dto/LoginRequestDTO.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/dto/LoginRequestDTO.java new file mode 100644 index 0000000..b643b37 --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/dto/LoginRequestDTO.java @@ -0,0 +1,9 @@ +package likelion.team1.mindscape.dto; + +import lombok.Data; + +@Data +public class LoginRequestDTO { + private String accountId; + private String password; +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/dto/UserResponseDTO.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/dto/UserResponseDTO.java new file mode 100644 index 0000000..0845b76 --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/dto/UserResponseDTO.java @@ -0,0 +1,21 @@ +package likelion.team1.mindscape.dto; + +import likelion.team1.mindscape.entity.User; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class UserResponseDTO { + private Long id; + private String accountId; + private String username; + + public static UserResponseDTO from(User user) { + return UserResponseDTO.builder() + .id(user.getId()) + .accountId(user.getAccountId()) + .username(user.getUsername()) + .build(); + } +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/entity/User.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/entity/User.java new file mode 100644 index 0000000..33662c0 --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/entity/User.java @@ -0,0 +1,67 @@ +package likelion.team1.mindscape.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Column(nullable = false, unique = true) + private String accountId; + + @Column(nullable = false) + private String username; + + @Column + private String password; + + @Column + private String provider; // OAuth2 제공자 (google) + + @Column + private String providerId; // OAuth2 제공자가 제공하는 ID + + // 리프레시 토큰 저장 필드 + @Transient // DB컬럼 생성 제외 - redis로 관리하기 때문에. + private String refreshToken; + + // 리프레시 토큰 만료기간 + @Transient // DB컬럼 생성 제외 - redis로 관리하기 때문에. + private LocalDateTime tokenExpiryDate; + + // 리프레시 토큰 만료기간 업데이트 + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + this.tokenExpiryDate = LocalDateTime.now().plusDays(14); + } + + // 리프레시 토큰 유효성 검사 + public boolean isRefreshTokenValid() { + return refreshToken != null && //refreshToken 있어야하고, + tokenExpiryDate != null && // tokenExpiryDate 있어야하고, + LocalDateTime.now().isBefore(tokenExpiryDate); // 재발급시기가 올바른시점이여야 한다. + } + + // 리프레시 토큰 제거 (로그아웃 등) + public void clearRefreshToken() { + this.refreshToken = null; + this.tokenExpiryDate = null; + } + + public boolean vailateRefreshToken(String refreshToken) { + return this.refreshToken.equals(refreshToken); + } +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/repository/UserRepository.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/repository/UserRepository.java new file mode 100644 index 0000000..80de267 --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/repository/UserRepository.java @@ -0,0 +1,8 @@ +package likelion.team1.mindscape.repository; + +import likelion.team1.mindscape.entity.User; +import org.springframework.data.repository.CrudRepository; + +public interface UserRepository extends CrudRepository { + User findByAccountId(String accountid); +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/security/CorsConfig.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/CorsConfig.java new file mode 100644 index 0000000..888e2ec --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/CorsConfig.java @@ -0,0 +1,42 @@ +package likelion.team1.mindscape.security; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +import java.util.Arrays; + +@Configuration +public class CorsConfig { + + @Value("${server.frontend.pageUrl}") + private String pageUrl; + + @Bean + public CorsFilter corsFilter() { + // CORS 설정을 URL 패턴별로 적용할 수 있게 해주는 클래스 + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + + CorsConfiguration config = new CorsConfiguration(); + + // allowCredentials를 true로 설정하면 allowedOrigins에는 *를 사용할 수 없습니다. + config.setAllowCredentials(true); + + // 구체적인 origin을 설정 + config.addAllowedOrigin(pageUrl); + + // 모든 헤더와 메소드 허용 + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + + // preflight 요청의 캐시 시간을 1시간으로 설정 + config.setMaxAge(3600L); + + source.registerCorsConfiguration("/**", config); + return new CorsFilter(source); + + } +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/security/RedisConfig.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/RedisConfig.java new file mode 100644 index 0000000..9b6b05e --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/RedisConfig.java @@ -0,0 +1,36 @@ +package likelion.team1.mindscape.security; + +import likelion.team1.mindscape.security.jwt.JwtProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${server.redis.host}") + private String redisHost; + + @Value("${server.redis.port}") + private int redisPort; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(redisHost, redisPort); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } + + +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/security/SecurityConfig.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/SecurityConfig.java new file mode 100644 index 0000000..ad68b2d --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/SecurityConfig.java @@ -0,0 +1,102 @@ +package likelion.team1.mindscape.security; + + +import likelion.team1.mindscape.repository.UserRepository; +import likelion.team1.mindscape.security.jwt.JwtAuthenticationFilter; +import likelion.team1.mindscape.security.jwt.JwtAuthorizationFilter; +import likelion.team1.mindscape.security.jwt.JwtProperties; +import likelion.team1.mindscape.security.oauth.OAuth2FailureHandler; +import likelion.team1.mindscape.security.oauth.OAuth2SuccessHandler; +import likelion.team1.mindscape.service.OAuth2UserService; +import likelion.team1.mindscape.service.RedisRefreshTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration // 스프링의 설정 클래스임을 나타내는 어노테이션 +@EnableWebSecurity // Spring Security 설정을 활성화하는 어노테이션 +@RequiredArgsConstructor // final 필드에 대한 생성자를 자동으로 생성하는 롬복 어노테이션 +public class SecurityConfig { + + private final UserRepository userRepository; + private final CorsConfig corsConfig; + private final JwtProperties jwtProperties; + private final RedisRefreshTokenService redisRefreshTokenService; + private final OAuth2UserService oAuth2UserService; + + + + //AuthenticationManager 빈을 생성하는 메소드 + //스프링 시큐리티의 인증을 담당하는 매니저를 설정 + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception { + return authenticationConfiguration.getAuthenticationManager(); + } + + //비밀번호 암호화를 위한 인코더를 빈으로 등록 + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + //스프링 시큐리티의 필터 체인을 구성하는 메소드 + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http, + OAuth2SuccessHandler oAuth2SuccessHandler, + OAuth2FailureHandler oAuth2FailureHandler, + AuthenticationManager authenticationManager) throws Exception { + http + .addFilter(corsConfig.corsFilter()) + // JWT 인증 필터 추가 + .addFilter(new JwtAuthenticationFilter(authenticationManager, jwtProperties, redisRefreshTokenService)) + + // JWT 인가 필터 추가 + .addFilter(new JwtAuthorizationFilter(authenticationManager, userRepository, jwtProperties)) + + // google OAuth 로그인 + .oauth2Login(oauth2 -> oauth2.userInfoEndpoint( + userInfo -> userInfo.userService(oAuth2UserService)) + .successHandler(oAuth2SuccessHandler) + .failureHandler(oAuth2FailureHandler) + ) + + // CSRF 보호 비활성화 (JWT 사용으로 불필요) 왜지? + // JWT를 사용하는 REST API에서는 CSRF 공격 방지가 불필요 + // 토큰 기반 인증이 CSRF 공격을 방지할 수 있기 때문 + .csrf(AbstractHttpConfigurer::disable) + + // 세션 설정 (JWT는 세션을 사용하지 않음) + // JWT는 상태를 저장하지 않는(stateless) 방식이므로 세션이 불필요 + // 서버의 확장성과 성능 향상을 위해 세션을 사용하지 않음 + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + + // 폼 로그인 비활성화 + // REST API에서는 폼 로그인 방식을 사용하지 않음 + // JWT 토큰 기반의 인증을 사용하므로 불필요 + .formLogin(AbstractHttpConfigurer::disable) + + // HTTP Basic 인증 비활성화 + // 기본 인증은 보안에 취약하고 JWT를 사용하므로 불필요 + // 매 요청마다 인증 정보를 보내는 방식이라 보안에 취약 + .httpBasic(AbstractHttpConfigurer::disable) + + + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/api/**").authenticated() //모든 API 호출 유저 인증 필요. + .anyRequest().permitAll()); // 이 외 요청은 권한 필요 X + + + return http.build(); + } + +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/security/jwt/JwtAuthenticationFilter.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..92aed4b --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,129 @@ +package likelion.team1.mindscape.security.jwt; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import likelion.team1.mindscape.dto.LoginRequestDTO; +import likelion.team1.mindscape.entity.User; +import likelion.team1.mindscape.repository.UserRepository; +import likelion.team1.mindscape.service.PrincipalDetails; +import likelion.team1.mindscape.service.RedisRefreshTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +import java.io.IOException; +import java.util.Date; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends UsernamePasswordAuthenticationFilter { + + //인증(Authentication)을 처리하는 핵심 인터페이스 + private final AuthenticationManager authenticationManager; + private final JwtProperties jwtProperties; + private final RedisRefreshTokenService redisRefreshTokenService; + + +// // 생성자에서 로그인 URL 설정 +// public JwtAuthenticationFilter(AuthenticationManager authenticationManager) { +// super(authenticationManager); +// // 로그인 URL 설정 - 기본값은 /login +// setFilterProcessesUrl("/login"); // 여기서 URL 변경 가능 +// } + + + // 인증 시도 메소드 + // 클라이언트로부터 받은 인증 정보로 로그인을 시도 + @Override + public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException { + + deleteExistingTokenCookies(response); + + // 1. HTTP 요청 본문을 DTO로 변환 + ObjectMapper mapper = new ObjectMapper(); + LoginRequestDTO loginRequestDTO = null; + + try { + loginRequestDTO = mapper.readValue(request.getInputStream(), LoginRequestDTO.class); + } catch (IOException e) { + throw new RuntimeException(e); + } + + // 2. 인증 토큰 생성 (아직 인증된거 아님) + UsernamePasswordAuthenticationToken authToken = + new UsernamePasswordAuthenticationToken(loginRequestDTO.getAccountId(), loginRequestDTO.getPassword()); + + // 3. 인증 검토 -> 여기서 인증 처리 + Authentication authentication = authenticationManager.authenticate(authToken); + + + return authentication; + } + + // JWT 토큰 발급 + // 인증 성공 시, 해당 메소드 호출 + @Override + protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain, Authentication authResult) throws IOException, ServletException { + + PrincipalDetails principalDetails = (PrincipalDetails) authResult.getPrincipal(); + + deleteExistingTokenCookies(response); + + // accessToken 생성 + String accessToken = JWT.create() + .withSubject(principalDetails.getAccountId()) + .withClaim("uid", principalDetails.getUser().getId()) + .withClaim("uname", principalDetails.getUser().getUsername()) + .withExpiresAt(new Date(System.currentTimeMillis() + jwtProperties.getACCESS_TOKEN_EXPIRATION())) + .sign(Algorithm.HMAC512(jwtProperties.getSECRET())); + + + String refreshToken = JWT.create() + .withSubject(principalDetails.getAccountId()) + .withExpiresAt(new Date(System.currentTimeMillis() + jwtProperties.getREFRESH_TOKEN_EXPIRATION())) + .sign(Algorithm.HMAC512(jwtProperties.getSECRET())); + + + + redisRefreshTokenService.saveRefreshToken(principalDetails.getAccountId(), refreshToken, + jwtProperties.getREFRESH_TOKEN_EXPIRATION()); + + + + String accessCookie = String.format( + "AccessToken=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=None", + accessToken,jwtProperties.getACCESS_TOKEN_EXPIRATION()); + + + String refreshCookie = String.format( + "RefreshToken=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=None", + refreshToken, jwtProperties.getREFRESH_TOKEN_EXPIRATION()); + + + + response.addHeader("Set-Cookie", accessCookie); + response.addHeader("Set-Cookie", refreshCookie); + } + + // 기존 토큰 쿠키 삭제 메서드 + private void deleteExistingTokenCookies(HttpServletResponse response) { + // AccessToken 만료 처리 + String expiredAccessToken = "AccessToken=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=None"; + + // RefreshToken 만료 처리 + String expiredRefreshToken = "RefreshToken=; Path=/; Max-Age=0; HttpOnly; Secure; SameSite=None"; + + // 응답 헤더로 만료 쿠키 추가 + response.addHeader("Set-Cookie", expiredAccessToken); + response.addHeader("Set-Cookie", expiredRefreshToken); + } +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/security/jwt/JwtAuthorizationFilter.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/jwt/JwtAuthorizationFilter.java new file mode 100644 index 0000000..4d2bbae --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/jwt/JwtAuthorizationFilter.java @@ -0,0 +1,131 @@ +package likelion.team1.mindscape.security.jwt; + + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.TokenExpiredException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import likelion.team1.mindscape.entity.User; +import likelion.team1.mindscape.repository.UserRepository; +import likelion.team1.mindscape.service.PrincipalDetails; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; + +import java.io.IOException; +import java.util.Date; + + +public class JwtAuthorizationFilter extends BasicAuthenticationFilter { + + private final UserRepository userRepository; + private final JwtProperties jwtProperties; + + public JwtAuthorizationFilter(AuthenticationManager authenticationManager, UserRepository userRepository, JwtProperties jwtProperties) { + super(authenticationManager); + this.userRepository = userRepository; + this.jwtProperties = jwtProperties; + } + // 실제 필터링 로직이 수행되는 메소드 + // JWT 토큰을 검증하고 인증 정보를 설정 + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException { + + //jwt 토큰 가져오기 + String accessToken = null; + String refreshToken = null; + + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if (cookie.getName().equals(JwtProperties.ACCESS_TOKEN_STRING)) { + accessToken = cookie.getValue(); + } + if (cookie.getName().equals(JwtProperties.REFRESH_TOKEN_STRING)) { + refreshToken = cookie.getValue(); + } + } + } else { + chain.doFilter(request, response); + return; + } + + try { + // JWT 검증 및 사용자 정보 추출 + String accountId = JWT.require(Algorithm.HMAC512(jwtProperties.getSECRET())) + .build() + .verify(accessToken) + .getSubject(); + + if(accountId != null) { + // 추출된 사용자가 현재 DB에 등록된 사용자인지 확인 + User user = userRepository.findByAccountId(accountId); + + // 사용자 정보 + PrincipalDetails principalDetails = new PrincipalDetails(user); + + Authentication authentication = + new UsernamePasswordAuthenticationToken( + principalDetails, // 사용자 정보 + null, // 인증 완료로 비밀번호 불필요 + principalDetails.getAuthorities()); // 권한 정보 + + //SecurityContext에 인증 정보 저장 + SecurityContextHolder.getContext().setAuthentication(authentication); + } + } catch (TokenExpiredException e){ // 토큰이 만료된 경우 + + if(refreshToken != null) { + + String accountId = JWT.require(Algorithm.HMAC512(jwtProperties.getSECRET())) + .build() + .verify(refreshToken) + .getSubject(); + + User user = userRepository.findByAccountId(accountId); + + if(user.vailateRefreshToken(refreshToken)) { + + String newAccessToken = JWT.create() + .withSubject(user.getAccountId()) + .withClaim("uid", user.getId()) + .withClaim("uname", user.getUsername()) + .withExpiresAt(new Date(System.currentTimeMillis() + jwtProperties.getACCESS_TOKEN_EXPIRATION())) + .sign(Algorithm.HMAC512(jwtProperties.getSECRET())); + + String accessCookie = String.format( + "AccessToken=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=None", + newAccessToken,jwtProperties.getACCESS_TOKEN_EXPIRATION()); + response.addHeader("Set-Cookie", accessCookie); + + // 인증 정보 설정 + PrincipalDetails principalDetails = new PrincipalDetails(user); + + Authentication authentication = new UsernamePasswordAuthenticationToken( + principalDetails, + null, + principalDetails.getAuthorities()); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + } + + } else { + chain.doFilter(request, response); + return; + } + + } catch (Exception ex) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); +// throw new RuntimeException(ex); + } + + chain.doFilter(request, response); + } +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/security/jwt/JwtProperties.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/jwt/JwtProperties.java new file mode 100644 index 0000000..532bb52 --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/jwt/JwtProperties.java @@ -0,0 +1,23 @@ +package likelion.team1.mindscape.security.jwt; + +import lombok.Getter; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Getter +@Component +public class JwtProperties { + + @Value("${jwt.secret}") + private String SECRET; + + @Value("${jwt.accessToken.ExpirationTime}") + private int ACCESS_TOKEN_EXPIRATION; + + @Value("${jwt.refreshToken.ExpirationTime}") + private int REFRESH_TOKEN_EXPIRATION; + + public static final String ACCESS_TOKEN_STRING = "AccessToken"; + public static final String REFRESH_TOKEN_STRING = "RefreshToken"; +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/security/oauth/OAuth2FailureHandler.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/oauth/OAuth2FailureHandler.java new file mode 100644 index 0000000..0b73245 --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/oauth/OAuth2FailureHandler.java @@ -0,0 +1,23 @@ +package likelion.team1.mindscape.security.oauth; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class OAuth2FailureHandler implements AuthenticationFailureHandler { + + @Value("${server.frontend.pageUrl}") + private String redirectPageUrl; + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + response.sendRedirect(redirectPageUrl); + } +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/security/oauth/OAuth2SuccessHandler.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/oauth/OAuth2SuccessHandler.java new file mode 100644 index 0000000..0acd32a --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/security/oauth/OAuth2SuccessHandler.java @@ -0,0 +1,85 @@ +package likelion.team1.mindscape.security.oauth; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import likelion.team1.mindscape.entity.User; +import likelion.team1.mindscape.repository.UserRepository; +import likelion.team1.mindscape.security.jwt.JwtProperties; +import likelion.team1.mindscape.service.PrincipalDetails; +import likelion.team1.mindscape.service.RedisRefreshTokenService; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { + + @Value("${server.frontend.pageUrl}") + private String redirectPageUrl; + + private final UserRepository userRepository; + private final JwtProperties jwtProperties; + private final RedisRefreshTokenService redisRefreshTokenService; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + + PrincipalDetails principalDetails = (PrincipalDetails) authentication.getPrincipal(); + User requestUser = principalDetails.getUser(); + + String accountid = requestUser.getAccountId(); + + User user = userRepository.findByAccountId(accountid); + if (user == null) { + throw new ServletException("User not found"); + } + + String accessToken = JWT.create() + .withSubject(user.getAccountId()) + .withClaim("uid", user.getId()) + .withClaim("uname", user.getUsername()) + .withExpiresAt(new Date(System.currentTimeMillis() + jwtProperties.getACCESS_TOKEN_EXPIRATION())) + .sign(Algorithm.HMAC512(jwtProperties.getSECRET())); + + String refreshToken = JWT.create() + .withSubject(user.getAccountId()) + .withExpiresAt(new Date(System.currentTimeMillis() + jwtProperties.getREFRESH_TOKEN_EXPIRATION())) + .sign(Algorithm.HMAC512(jwtProperties.getSECRET())); + + redisRefreshTokenService.saveRefreshToken(user.getAccountId(), refreshToken, + jwtProperties.getREFRESH_TOKEN_EXPIRATION()); + + + String accessCookie = String.format( + "AccessToken=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=None", + accessToken,jwtProperties.getACCESS_TOKEN_EXPIRATION()); + + + + String refreshCookie = String.format( + "RefreshToken=%s; Path=/; Max-Age=%d; HttpOnly; Secure; SameSite=None", + refreshToken, jwtProperties.getREFRESH_TOKEN_EXPIRATION()); + + + Cookie jsessionidCookie = new Cookie("JSESSIONID", null); + jsessionidCookie.setMaxAge(0); + jsessionidCookie.setPath("/"); + + + response.addHeader("Set-Cookie", accessCookie); + response.addHeader("Set-Cookie", refreshCookie); + response.addCookie(jsessionidCookie); + + response.sendRedirect(redirectPageUrl); + } +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/service/OAuth2UserService.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/service/OAuth2UserService.java new file mode 100644 index 0000000..abeb2bf --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/service/OAuth2UserService.java @@ -0,0 +1,44 @@ +package likelion.team1.mindscape.service; + +import likelion.team1.mindscape.entity.User; +import likelion.team1.mindscape.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class OAuth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + String provider = userRequest.getClientRegistration().getRegistrationId(); + String providerId = oAuth2User.getAttribute("sub"); + String email = oAuth2User.getAttribute("email"); + String name = oAuth2User.getAttribute("name"); + + // 구글 로그인에서는 accountId = email로 취급. + User user = userRepository.findByAccountId(email); + + if(user == null){ + user = User.builder() + .accountId(email) + .username(name) + .provider(provider) + .providerId(providerId) + .build(); + + userRepository.save(user); + } + + + return new PrincipalDetails(user, oAuth2User.getAttributes()); + } +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/service/PrincipalDetails.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/service/PrincipalDetails.java new file mode 100644 index 0000000..b596a4b --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/service/PrincipalDetails.java @@ -0,0 +1,59 @@ +package likelion.team1.mindscape.service; + +import likelion.team1.mindscape.entity.User; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.Collection; +import java.util.List; +import java.util.Map; + +@Getter +public class PrincipalDetails implements UserDetails, OAuth2User { + + private final User user; + private Map attributes; + + public PrincipalDetails(User user) { + this.user = user; + } + + public PrincipalDetails(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } + + @Override + public Map getAttributes() { + return attributes; + } + + @Override + public Collection getAuthorities() { + return List.of(); + } + + @Override + public String getPassword() { + return user.getPassword(); + } + + @Override + public String getUsername() { + return user.getUsername(); + } + + public String getAccountId() { + return user.getAccountId(); + } + + @Override + public String getName() { + return user.getUsername(); + } + + +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/service/PrincipalDetailsService.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/service/PrincipalDetailsService.java new file mode 100644 index 0000000..776037f --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/service/PrincipalDetailsService.java @@ -0,0 +1,25 @@ +package likelion.team1.mindscape.service; + +import likelion.team1.mindscape.entity.User; +import likelion.team1.mindscape.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class PrincipalDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + // 로그인 시도할 때 스프링 시큐리티가 자동으로 호출하는 메소드 + // acoountid로 DB에서 사용자를 조회하여 PrincipalDetails 객체로 변환 + @Override + public UserDetails loadUserByUsername(String accountid) throws UsernameNotFoundException { + User user = userRepository.findByAccountId(accountid); + return new PrincipalDetails(user); + } + +} diff --git a/mindscape-auth/src/main/java/likelion/team1/mindscape/service/RedisRefreshTokenService.java b/mindscape-auth/src/main/java/likelion/team1/mindscape/service/RedisRefreshTokenService.java new file mode 100644 index 0000000..cccc121 --- /dev/null +++ b/mindscape-auth/src/main/java/likelion/team1/mindscape/service/RedisRefreshTokenService.java @@ -0,0 +1,35 @@ +package likelion.team1.mindscape.service; + +import likelion.team1.mindscape.security.jwt.JwtProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.concurrent.TimeUnit; + +@Service +@RequiredArgsConstructor +public class RedisRefreshTokenService { + private final RedisTemplate redisTemplate; + private static final String REFRESH_TOKEN_PREFIX = JwtProperties.REFRESH_TOKEN_STRING + ":"; + + public void saveRefreshToken(String accountId, String refreshToken, long expirationTime) { + String key = REFRESH_TOKEN_PREFIX + accountId; + redisTemplate.opsForValue().set(key, refreshToken, expirationTime, TimeUnit.MILLISECONDS); + } + + public String getRefreshToken(String accountId) { + String key = REFRESH_TOKEN_PREFIX + accountId; + return redisTemplate.opsForValue().get(key); + } + + public void deleteRefreshToken(String accountId) { + String key = REFRESH_TOKEN_PREFIX + accountId; + redisTemplate.delete(key); + } + + public boolean validateRefreshToken(String accountId, String refreshToken) { + String savedToken = getRefreshToken(accountId); + return refreshToken.equals(savedToken); + } +} diff --git a/mindscape-auth/src/main/resources/application.properties b/mindscape-auth/src/main/resources/application.properties deleted file mode 100644 index 6618ef0..0000000 --- a/mindscape-auth/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=mindscape diff --git a/mindscape-info/.gitignore b/mindscape-info/.gitignore index a43b27c..4622b4b 100644 --- a/mindscape-info/.gitignore +++ b/mindscape-info/.gitignore @@ -31,4 +31,10 @@ build/ ### VS Code ### .vscode/ + + + +### properties ### + *.properties + diff --git a/mindscape-info/Dockerfile b/mindscape-info/Dockerfile new file mode 100644 index 0000000..b71999c --- /dev/null +++ b/mindscape-info/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17 +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar", "/app.jar"] \ No newline at end of file diff --git a/mindscape-info/pom.xml b/mindscape-info/pom.xml index 2b7383d..f6a9fbd 100644 --- a/mindscape-info/pom.xml +++ b/mindscape-info/pom.xml @@ -44,13 +44,48 @@ org.projectlombok lombok - true + 1.18.30 + provided org.springframework.boot spring-boot-starter-test test + + org.springframework.boot + spring-boot-starter-data-jpa + + + com.mysql + mysql-connector-j + 8.0.33 + + + com.auth0 + java-jwt + 4.4.0 + + + + io.jsonwebtoken + jjwt-api + 0.11.5 + + + io.jsonwebtoken + jjwt-impl + 0.11.5 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.5 + runtime + + + @@ -58,11 +93,13 @@ org.apache.maven.plugins maven-compiler-plugin + 3.11.0 org.projectlombok lombok + 1.18.30 @@ -71,12 +108,6 @@ org.springframework.boot spring-boot-maven-plugin - - - org.projectlombok - lombok - - diff --git a/mindscape-info/src/main/java/likelion/team1/mindscape/WebConfig.java b/mindscape-info/src/main/java/likelion/team1/mindscape/WebConfig.java new file mode 100644 index 0000000..5d752ef --- /dev/null +++ b/mindscape-info/src/main/java/likelion/team1/mindscape/WebConfig.java @@ -0,0 +1,22 @@ +package likelion.team1.mindscape; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${server.frontend.pageUrl}") + private String pageUrl; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") // 모든 요청 경로에 대해 + .allowedOrigins(pageUrl) //서비스 페이지 + .allowedMethods("*") // GET, POST, PUT 등 모두 허용 + .allowedHeaders("*") // 모든 헤더 허용 + .allowCredentials(true); // 인증 정보 포함 허용 (JWT 등) + } +} diff --git a/mindscape-info/src/main/java/likelion/team1/mindscape/test/controller/TestController.java b/mindscape-info/src/main/java/likelion/team1/mindscape/test/controller/TestController.java new file mode 100644 index 0000000..79c45f7 --- /dev/null +++ b/mindscape-info/src/main/java/likelion/team1/mindscape/test/controller/TestController.java @@ -0,0 +1,63 @@ +package likelion.team1.mindscape.test.controller; + +import likelion.team1.mindscape.test.dto.TestRequestDto; +import likelion.team1.mindscape.test.dto.TestResponseDto; +import likelion.team1.mindscape.test.dto.TestResponseSimpleDto; +import likelion.team1.mindscape.test.service.TestService; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/test") +@RequiredArgsConstructor +public class TestController { + + private final TestService testService; + + //테스트 저장 + @PostMapping("/save") + public ResponseEntity> saveTest(@RequestParam("id") Long userId, + @RequestBody TestRequestDto dto) { + dto.setUserId(userId); // 직접 주입 + Long testId = testService.saveTest(dto); + return ResponseEntity.ok(Map.of("testId", testId)); + } + + // 마이페이지: 내 테스트 히스토리 + @GetMapping("/history") + public ResponseEntity> getHistory(@RequestParam("id") Long userId) { + List history = testService.getTestHistory(userId); + return ResponseEntity.ok(history); + } + + + @GetMapping("/internal/tests/{testId}") + public ResponseEntity getTestInfo(@PathVariable Long testId) { + TestResponseSimpleDto response = testService.getTestInfoById(testId); + return ResponseEntity.ok(response); + } + + @GetMapping("/ids") + public ResponseEntity> getTestIdsByUser(@RequestParam("id") Long userId, + @RequestParam int page, + @RequestParam int size) { + Pageable pageable = PageRequest.of(page, size); + List testIds = testService.getTestIdsByUserId(userId, pageable); + return ResponseEntity.ok(testIds); + } + @GetMapping("/type/ids") + public ResponseEntity> getTestIdsByUserType(@RequestParam("userType") String userType, + @RequestParam(defaultValue = "3") int size) { + + Pageable pageable = PageRequest.of(0, size); + List testIds = testService.getTestIdsByUserType(userType, pageable); + return ResponseEntity.ok(testIds); + } +} diff --git a/mindscape-info/src/main/java/likelion/team1/mindscape/test/dto/TestRequestDto.java b/mindscape-info/src/main/java/likelion/team1/mindscape/test/dto/TestRequestDto.java new file mode 100644 index 0000000..860b834 --- /dev/null +++ b/mindscape-info/src/main/java/likelion/team1/mindscape/test/dto/TestRequestDto.java @@ -0,0 +1,14 @@ +package likelion.team1.mindscape.test.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TestRequestDto { + private Long userId; + private String userType; + private String typeDescription; +} diff --git a/mindscape-info/src/main/java/likelion/team1/mindscape/test/dto/TestResponseDto.java b/mindscape-info/src/main/java/likelion/team1/mindscape/test/dto/TestResponseDto.java new file mode 100644 index 0000000..9225236 --- /dev/null +++ b/mindscape-info/src/main/java/likelion/team1/mindscape/test/dto/TestResponseDto.java @@ -0,0 +1,17 @@ +package likelion.team1.mindscape.test.dto; + +import lombok.*; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TestResponseDto { + private Long testId; + private String userType; + private String typeDescription; + private LocalDateTime createdAt; +} diff --git a/mindscape-info/src/main/java/likelion/team1/mindscape/test/dto/TestResponseSimpleDto.java b/mindscape-info/src/main/java/likelion/team1/mindscape/test/dto/TestResponseSimpleDto.java new file mode 100644 index 0000000..49b2979 --- /dev/null +++ b/mindscape-info/src/main/java/likelion/team1/mindscape/test/dto/TestResponseSimpleDto.java @@ -0,0 +1,14 @@ +package likelion.team1.mindscape.test.dto; + +import lombok.*; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class TestResponseSimpleDto { + private Long testId; + private Long userId; + private String userType; +} diff --git a/mindscape-info/src/main/java/likelion/team1/mindscape/test/entity/Test.java b/mindscape-info/src/main/java/likelion/team1/mindscape/test/entity/Test.java new file mode 100644 index 0000000..78d2f9f --- /dev/null +++ b/mindscape-info/src/main/java/likelion/team1/mindscape/test/entity/Test.java @@ -0,0 +1,32 @@ +package likelion.team1.mindscape.test.entity; + +import jakarta.persistence.*; +import lombok.*; +import java.time.LocalDateTime; + +@Entity +@Getter @Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "test") +public class Test { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long testId; + + private String userType; // 예: 적극 참여형 + + private LocalDateTime createdAt; + + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + } + + private String typeDescription; + + @Column(name = "user_id", nullable = false) + private Long userId; +} diff --git a/mindscape-info/src/main/java/likelion/team1/mindscape/test/repository/TestRepository.java b/mindscape-info/src/main/java/likelion/team1/mindscape/test/repository/TestRepository.java new file mode 100644 index 0000000..a65db25 --- /dev/null +++ b/mindscape-info/src/main/java/likelion/team1/mindscape/test/repository/TestRepository.java @@ -0,0 +1,21 @@ +package likelion.team1.mindscape.test.repository; + +import likelion.team1.mindscape.test.entity.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + + +public interface TestRepository extends JpaRepository { + List findByUserIdOrderByCreatedAtDesc(Long userId); + + Page findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable); + + Page findByUserType(String userType, Pageable pageable); + +// Long findUserIdByTestId(Long testId); +} + diff --git a/mindscape-info/src/main/java/likelion/team1/mindscape/test/service/TestService.java b/mindscape-info/src/main/java/likelion/team1/mindscape/test/service/TestService.java new file mode 100644 index 0000000..1461092 --- /dev/null +++ b/mindscape-info/src/main/java/likelion/team1/mindscape/test/service/TestService.java @@ -0,0 +1,70 @@ +package likelion.team1.mindscape.test.service; + +import jakarta.transaction.Transactional; +import likelion.team1.mindscape.test.dto.TestRequestDto; +import likelion.team1.mindscape.test.dto.TestResponseDto; +import likelion.team1.mindscape.test.dto.TestResponseSimpleDto; +import likelion.team1.mindscape.test.entity.Test; +import likelion.team1.mindscape.test.repository.TestRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class TestService { + + private final TestRepository testRepository; + + @Transactional + public Long saveTest(TestRequestDto dto) { + Test test = Test.builder() + .userId(dto.getUserId()) + .userType(dto.getUserType()) + .typeDescription(dto.getTypeDescription()) + .build(); + + + + return testRepository.save(test).getTestId(); + } + + public List getTestHistory(Long userId) { + List testList = testRepository.findByUserIdOrderByCreatedAtDesc(userId); + + return testList.stream() + .map(test -> TestResponseDto.builder() + .testId(test.getTestId()) + .userType(test.getUserType()) + .typeDescription(test.getTypeDescription()) + .createdAt(test.getCreatedAt()) + .build()) + .collect(Collectors.toList()); + } + + public TestResponseSimpleDto getTestInfoById(Long testId) { + Test test = testRepository.findById(testId) + .orElseThrow(() -> new IllegalArgumentException("테스트 결과를 찾을 수 없습니다.")); + + return TestResponseSimpleDto.builder() + .testId(test.getTestId()) + .userId(test.getUserId()) // ✅ 객체 대신 userId 직접 반환 + .userType(test.getUserType()) + .build(); + } + public List getTestIdsByUserId(Long userId, Pageable pageable) { + Page page = testRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable).map(Test::getTestId); + List res = page.stream().toList(); + return res; + } + + public List getTestIdsByUserType(String userType, Pageable pageable) { + Page page = testRepository.findByUserType(userType, pageable).map(Test::getTestId); + List res = page.stream().toList(); + return res; + } +} \ No newline at end of file diff --git a/mindscape-info/src/main/java/likelion/team1/mindscape/test/util/JwtUtil.java b/mindscape-info/src/main/java/likelion/team1/mindscape/test/util/JwtUtil.java new file mode 100644 index 0000000..f0f6500 --- /dev/null +++ b/mindscape-info/src/main/java/likelion/team1/mindscape/test/util/JwtUtil.java @@ -0,0 +1,19 @@ +package likelion.team1.mindscape.test.util; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import java.nio.charset.StandardCharsets; + +public class JwtUtil { + + private static final String SECRET_KEY = "dlrjsJWTxhzmsdmfdkaghghk,alcqhrghkgkfEotkdydgksmszldlqslek!"; // auth-service에서 사용하는 서명 키와 동일해야 함 + + public static Long extractUserId(String token) { + Claims claims = Jwts.parser() + .setSigningKey(SECRET_KEY.getBytes(StandardCharsets.UTF_8)) + .parseClaimsJws(token.replace("Bearer ", "")) + .getBody(); + + return claims.get("userId", Long.class); // 클레임에서 userId 추출 + } +} diff --git a/mindscape-info/src/main/resources/application.properties b/mindscape-info/src/main/resources/application.properties deleted file mode 100644 index 1a935e2..0000000 --- a/mindscape-info/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=mindscape-info diff --git a/mindscape-service/.gitignore b/mindscape-service/.gitignore index a43b27c..f511436 100644 --- a/mindscape-service/.gitignore +++ b/mindscape-service/.gitignore @@ -31,4 +31,12 @@ build/ ### VS Code ### .vscode/ + + + +### properties ### *.properties + +# Redis AOF & RDB logs +redis/conf/appendonlydir/ +redis/conf/dump.rdb diff --git a/mindscape-service/Dockerfile b/mindscape-service/Dockerfile new file mode 100644 index 0000000..b71999c --- /dev/null +++ b/mindscape-service/Dockerfile @@ -0,0 +1,4 @@ +FROM openjdk:17 +ARG JAR_FILE=target/*.jar +COPY ${JAR_FILE} app.jar +ENTRYPOINT ["java", "-jar", "/app.jar"] \ No newline at end of file diff --git a/mindscape-service/pom.xml b/mindscape-service/pom.xml index ac497cb..da9339b 100644 --- a/mindscape-service/pom.xml +++ b/mindscape-service/pom.xml @@ -1,60 +1,102 @@ - + 4.0.0 + org.springframework.boot spring-boot-starter-parent 3.5.3 + likelion.team1 mindscape 0.0.1-SNAPSHOT mindscape-service Demo project for Spring Boot - - - - - - - - - - - - - + 17 + + org.springframework.boot spring-boot-starter-web + org.springframework.boot - spring-boot-devtools - runtime - true + spring-boot-starter-data-jpa + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + io.lettuce + lettuce-core + + + + + com.mysql + mysql-connector-j + 8.0.33 + + + + + org.json + json + 20230227 + + + + org.apache.httpcomponents + httpclient + 4.5.14 + + + org.projectlombok lombok + 1.18.34 + provided + + + + + org.springframework.boot + spring-boot-devtools + runtime true + + org.springframework.boot spring-boot-starter-test test + + com.google.genai + google-genai + 1.0.0 + + org.apache.maven.plugins maven-compiler-plugin @@ -63,23 +105,17 @@ org.projectlombok lombok + 1.18.34 + + org.springframework.boot spring-boot-maven-plugin - - - - org.projectlombok - lombok - - - - - + \ No newline at end of file diff --git a/mindscape-service/redis/conf/redis.conf b/mindscape-service/redis/conf/redis.conf new file mode 100644 index 0000000..7a8a62a --- /dev/null +++ b/mindscape-service/redis/conf/redis.conf @@ -0,0 +1,5 @@ +appendonly yes +appendfilename "appendonly.aof" +appendfsync everysec +dir redis/conf +daemonize yes diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/MindscapeServiceApplication.java b/mindscape-service/src/main/java/likelion/team1/mindscape/MindscapeServiceApplication.java index a5df12e..3ccf20e 100644 --- a/mindscape-service/src/main/java/likelion/team1/mindscape/MindscapeServiceApplication.java +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/MindscapeServiceApplication.java @@ -1,5 +1,6 @@ package likelion.team1.mindscape; + import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/WebConfig.java b/mindscape-service/src/main/java/likelion/team1/mindscape/WebConfig.java new file mode 100644 index 0000000..88c7c96 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/WebConfig.java @@ -0,0 +1,23 @@ +package likelion.team1.mindscape; + + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Value("${server.frontend.pageUrl}") + private String pageUrl; + + @Override + public void addCorsMappings(CorsRegistry registry) { + registry.addMapping("/**") // 모든 요청 경로에 대해 + .allowedOrigins(pageUrl) //서비스 페이지 + .allowedMethods("*") // GET, POST, PUT 등 모두 허용 + .allowedHeaders("*") // 모든 헤더 허용 + .allowCredentials(true); // 인증 정보 포함 허용 (JWT 등) + } +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/client/GeminiApiClient.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/client/GeminiApiClient.java new file mode 100644 index 0000000..b56f77d --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/client/GeminiApiClient.java @@ -0,0 +1,90 @@ +package likelion.team1.mindscape.content.client; +import com.google.genai.Client; +import com.google.genai.ResponseStream; +import com.google.genai.types.Content; +import com.google.genai.types.GenerateContentResponse; +import com.google.genai.types.Part; +import likelion.team1.mindscape.content.dto.response.GeminiResponse; +import likelion.team1.mindscape.content.global.config.GeminiConfig; +import lombok.RequiredArgsConstructor; +import org.json.JSONObject; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class GeminiApiClient { + + private final Client geminiClient; + private final GeminiConfig geminiConfig; + + public GeminiResponse getRecommendations(String prompt) { + // 1. 모델 이름 가져오기 + String modelName = geminiConfig.getModelName(); + + // 2. 프롬프트를 Content 객체로 변환 + var content = Content.fromParts(Part.fromText(prompt)); + + int maxRetries = 3; + int attempt = 0; + RuntimeException lastException = null; + + while (attempt < maxRetries) { + try { + attempt++; + + ResponseStream responseStream = geminiClient.models.generateContentStream(modelName, content, null); + + StringBuilder responseContent = new StringBuilder(); + responseStream.forEach(response -> responseContent.append(response.text())); + + System.out.println("\n\n=== API Response Start ===\n" + responseContent.toString() + "\n=== API Response End ===\n\n"); + + String rawResponse = responseContent.toString() + .replaceAll("```json", "") + .replaceAll("```", "") + .trim(); + + int jsonStart = rawResponse.indexOf("{"); + int jsonEnd = rawResponse.lastIndexOf("}") + 1; + + if (jsonStart < 0 || jsonEnd < 0 || jsonStart >= jsonEnd) { + throw new RuntimeException("응답에서 JSON을 찾을 수 없습니다."); + } + + String jsonString = rawResponse.substring(jsonStart, jsonEnd); + + // 여기서 간단한 문자열 정제 시도 + // 예: 콤마 뒤에 % 같은 특수문자 제거 또는 잘못된 따옴표 교정 + jsonString = jsonString.replaceAll(",\\s*['\"]?%['\"]?", ","); + + JSONObject resultJson = new JSONObject(jsonString); + + List movie = resultJson.getJSONArray("movie").toList().stream() + .map(Object::toString) + .collect(Collectors.toList()); + + List book = resultJson.getJSONArray("book").toList().stream() + .map(Object::toString) + .collect(Collectors.toList()); + + List music = resultJson.getJSONArray("music").toList().stream() + .map(Object::toString) + .collect(Collectors.toList()); + + return new GeminiResponse(movie, book, music); + + } catch (Exception e) { + lastException = new RuntimeException("Gemini 응답 파싱 실패 시도 #" + attempt, e); + System.err.println(lastException.getMessage()); + // 잠시 대기 후 재시도 (옵션) + try { Thread.sleep(500); } catch (InterruptedException ignored) {} + } + } + + // 재시도 실패 시 예외 던짐 + throw lastException; + } +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/client/TestServiceClient.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/client/TestServiceClient.java new file mode 100644 index 0000000..5976488 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/client/TestServiceClient.java @@ -0,0 +1,44 @@ +package likelion.team1.mindscape.content.client; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import likelion.team1.mindscape.content.dto.response.TestInfoResponse; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + + +@Component +public class TestServiceClient { + + private final RestTemplate restTemplate = new RestTemplate(); + + @Value("${info.app.url}") + private String infoAppUrl; + + // 주소 정확히 입력해야함 + public TestInfoResponse getTestInfo(Long testId) { + String url = infoAppUrl + "/internal/tests/" + testId; + return restTemplate.getForObject(url, TestInfoResponse.class); + + //---------------------------------------------------- + // 임시 하드코딩 (testId=1일 때 테스트용)!!!!!!!!!!!! +// return new TestInfoResponse(1L, 101L, "I"); + + } + + public List getTestIdsByUserId(Long userId, int page, int size) { + String url = infoAppUrl + "/ids?id=" + userId + "&page=" + page + "&size=" + size; + Long[] testIds = restTemplate.getForObject(url, Long[].class); + return testIds != null ? Arrays.asList(testIds) : Collections.emptyList(); + } + + public List getTestIdsByUserType(String userType, int size) { + String url = infoAppUrl + "/type/ids?userType=" + userType + "&size=" + size; + Long[] testIds = restTemplate.getForObject(url, Long[].class); + return testIds != null ? Arrays.asList(testIds) : Collections.emptyList(); + } +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/controller/ContentController.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/controller/ContentController.java new file mode 100644 index 0000000..667eac7 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/controller/ContentController.java @@ -0,0 +1,148 @@ +package likelion.team1.mindscape.content.controller; + +import likelion.team1.mindscape.content.dto.response.content.*; +import likelion.team1.mindscape.content.entity.Book; +import likelion.team1.mindscape.content.entity.Movie; +import likelion.team1.mindscape.content.entity.Music; +import likelion.team1.mindscape.content.enums.ContentType; +import likelion.team1.mindscape.content.service.BookService; +import likelion.team1.mindscape.content.service.MovieService; +import likelion.team1.mindscape.content.service.MusicService; +import likelion.team1.mindscape.content.service.RedisInitializer; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.io.IOException; +import java.util.*; +import java.util.stream.Collectors; + + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/content") +public class ContentController { + private final MovieService movieService; + private final BookService bookService; + private final MusicService musicService; + private static final String ARTIST_TITLE_DELIMITER_REGEX = "[–-]"; // en dash or hyphen + @GetMapping("/search") + public ResponseEntity search( + @RequestParam ContentType content, + @RequestParam(required = false) String query, // MOVIE + @RequestParam(required = false) String artist, // MUSIC + @RequestParam(required = false) String title // MUSIC/BOOK + ) throws IOException { + + Object result = switch (content) { + case MOVIE -> { + require(StringUtils.hasText(query), "MOVIE requires title"); + yield movieService.getMovieInfo(query); // returns List + } + case MUSIC -> { + require(StringUtils.hasText(artist) && StringUtils.hasText(title), + "MUSIC requires artist and title"); + yield musicService.getMusicDetail(artist, title); // returns MusicResponse + } + case BOOK -> { + require(StringUtils.hasText(title), "BOOK requires title"); + yield bookService.getBookDetail(title); // returns BookResponse + } + }; + + return ResponseEntity.ok(result); + } + + private static void require(boolean condition, String message) { + if (!condition) throw new IllegalArgumentException(message); + } + @GetMapping("/movie") + public ResponseEntity> getContents(@RequestParam("testId") Long testId) { + List updatedList = movieService.updateMovieFromTitle(testId); + Map result = new LinkedHashMap<>(); + result.put("updatedList", updatedList); // 기존 DB 기반 업데이트된 전체 + return ResponseEntity.ok(result); + } + + @GetMapping(value = "/book", params = "book") + public ResponseEntity getBookDetail(@RequestParam("book") String book) { + List bookList = splitter(book); + return handleBooks(bookList); + } + + @GetMapping(value = "/music", params = "music") + public ResponseEntity getMusicDetail(@RequestParam("music") String music) { + List musicList = splitter(music); + return handleMusic(musicList); + } + + @GetMapping(value = "/music", params = "testId") + public ResponseEntity getMusicByTestId(@RequestParam("testId") Long testId) { + try { + return ResponseEntity.ok(musicService.getMusicWithTestId(testId)); + } catch (IOException e) { + return ResponseEntity.internalServerError().body("Internal server error: " + e.getMessage()); + } + } + + @GetMapping(value = "/book", params = "testId") + public ResponseEntity getBooksByTestId(@RequestParam("testId") Long testId) { + try { + return ResponseEntity.ok(bookService.getBooksWithTestId(testId)); + } catch (IOException e) { + return ResponseEntity.internalServerError().body("Internal server error: " + e.getMessage()); + } + } + + private List splitter(String joined) { + return Arrays.stream(joined.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(Collectors.toList()); + } + + private ResponseEntity handleBooks(List bookList) { + try { + List responses = new ArrayList<>(); + for (String book : bookList) { + responses.add(bookService.getBookDetail(book)); + } + + List dtos = responses.stream().map(BookDto::from).collect(Collectors.toList()); + + return ResponseEntity.ok(dtos); + } catch (IOException e) { + return ResponseEntity.internalServerError().body("Internal server error: " + e.getMessage()); + } + } + + private ResponseEntity handleMusic(List musicList) { + List artistList = new ArrayList<>(); + List titleList = new ArrayList<>(); + + for (String m : musicList) { + String[] tmp = m.split(ARTIST_TITLE_DELIMITER_REGEX, 2); + if (tmp.length == 2) { + artistList.add(tmp[0].trim()); + titleList.add(tmp[1].trim()); + } + } + if (artistList.isEmpty() || artistList.size() != titleList.size()) { + return ResponseEntity.badRequest().body("Invalid format. Use 'artist-title'"); + } + + try { + List responses = new ArrayList<>(); + for (int i = 0; i < artistList.size(); i++) { + responses.add(musicService.getMusicDetail(artistList.get(i), titleList.get(i))); + } + + List dtos = responses.stream().map(MusicDto::from).collect(Collectors.toList()); + + return ResponseEntity.ok(dtos); + } catch (IOException e) { + return ResponseEntity.internalServerError().body("Internal server error: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/controller/GeminiController.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/controller/GeminiController.java new file mode 100644 index 0000000..4c71c53 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/controller/GeminiController.java @@ -0,0 +1,28 @@ +package likelion.team1.mindscape.content.controller; + +import likelion.team1.mindscape.content.service.ContentService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import likelion.team1.mindscape.content.dto.response.GeminiResponse; +import likelion.team1.mindscape.content.service.GeminiService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class GeminiController { + private final GeminiService geminiService; + private final ContentService contentService; + + //front에서 testid를 받아오는 것 + @PostMapping("/api/gemini/recommend") + public ResponseEntity recommend(@RequestParam Long testId) { + GeminiResponse response = geminiService.recommend(testId); + //contentService.saveAllRecomContent(testId, response); + return ResponseEntity.ok(response); // recomId 없이 응답만 OK 처리 + } + +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/controller/ResponseController.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/controller/ResponseController.java new file mode 100644 index 0000000..4026c88 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/controller/ResponseController.java @@ -0,0 +1,104 @@ +package likelion.team1.mindscape.content.controller; + +import likelion.team1.mindscape.content.client.TestServiceClient; +import likelion.team1.mindscape.content.dto.response.HistoryResponse; +import likelion.team1.mindscape.content.dto.response.content.*; +import likelion.team1.mindscape.content.global.security.AESUtil; +import likelion.team1.mindscape.content.service.ResponseService; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.*; + +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.List; + +@RestController +@RequestMapping("/api/response") +@RequiredArgsConstructor +public class ResponseController { + + private final AESUtil aesUtil; + private final ResponseService responseService; + private final TestServiceClient testServiceClient; + + @GetMapping(value = "/history", params = "testId") + public ResponseEntity getHistoryByTestId(@RequestParam Long testId) { + List bookList = responseService.getBookDtoByTestId(testId); + + List musicList = responseService.getMusicDtoByTestId(testId); + + List movieList = responseService.getMovieDtoByTestId(testId); + + HistoryResponse.Recommend recommend = new HistoryResponse.Recommend(bookList, musicList, movieList); + HistoryResponse response = new HistoryResponse(testId, recommend); + + return ResponseEntity.ok(response); + } + + @GetMapping(value = "/history", params = {"userId", "page", "size"}) + public ResponseEntity> getHistoryByUserId(@RequestParam Long userId, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "5") int size) { + //TODO: get test ID with user ID + List testIds = testServiceClient.getTestIdsByUserId(userId, page, size); + List responses = new ArrayList<>(); + + for (Long testId : testIds) { + responses.add(getHistoryByTestId(testId).getBody()); + } + return ResponseEntity.ok().body(responses); + } + + @GetMapping(value = "/ranking", params = {"type", "size"}) + public ResponseEntity getRanking(@RequestParam String type, + @RequestParam(defaultValue = "3") int size) { + List testIds = testServiceClient.getTestIdsByUserType(type, size); + + return ResponseEntity.ok(responseService.getRankingResponse(testIds, size)); + } + + @GetMapping(value = "/share/{testId}/{name}") + public ResponseEntity createShareLink(@PathVariable String testId, + @PathVariable String name, Model model) throws Exception { + + String rowData = testId + "|" + name; + + //testId 암호화 + String encryptedTestId = aesUtil.encrypt(rowData); + + //testId URL값에 맞게 인코딩 + String sharingURLvalue = URLEncoder.encode(encryptedTestId, "UTF-8"); + + model.addAttribute("value", sharingURLvalue); + + return ResponseEntity.ok(model); + } + + @GetMapping(value = "/share", params = "value") + public ResponseEntity getHistoryBySharingURLvalue(@RequestParam String value, Model model) throws Exception { + + //testId URL값에 맞게 인코딩 + String decodedData = URLDecoder.decode(value, "UTF-8"); + + //testId 복호화 + String encryptedTestId = aesUtil.decrypt(decodedData); + + + String testId = encryptedTestId.split("\\|")[0]; + + String name = encryptedTestId.split("\\|")[1]; + + + ResponseEntity recommendHistory = getHistoryByTestId(Long.parseLong(testId)); + + model.addAttribute("name", name); + model.addAttribute("RecommendHistory", recommendHistory); + + return ResponseEntity.ok(model); + } + + +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/ContentCountDto.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/ContentCountDto.java new file mode 100644 index 0000000..c2f3aaf --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/ContentCountDto.java @@ -0,0 +1,22 @@ +package likelion.team1.mindscape.content.dto; + +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; + +import java.util.List; + +@Getter +@Setter +@ToString +public class ContentCountDto { + private String title; + private Long cnt; + private List ids; + + public ContentCountDto(String title, Long cnt, List ids) { + this.title = title; + this.cnt = cnt; + this.ids = ids; + } +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/TestInfoResponse.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/TestInfoResponse.java new file mode 100644 index 0000000..8cba1ef --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/TestInfoResponse.java @@ -0,0 +1,14 @@ +package likelion.team1.mindscape.content.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor // 기본 생성자 +@AllArgsConstructor // 모든 필드를 받는 생성자 +public class TestInfoResponse { + + private Long userId; + private String userType; +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/request/a b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/request/a new file mode 100644 index 0000000..e69de29 diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/GeminiResponse.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/GeminiResponse.java new file mode 100644 index 0000000..21ed9e7 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/GeminiResponse.java @@ -0,0 +1,16 @@ +package likelion.team1.mindscape.content.dto.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class GeminiResponse { + private List movie; + private List book; + private List music; +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/HistoryResponse.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/HistoryResponse.java new file mode 100644 index 0000000..cc8af7d --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/HistoryResponse.java @@ -0,0 +1,33 @@ +package likelion.team1.mindscape.content.dto.response; + +import com.fasterxml.jackson.annotation.JsonProperty; +import likelion.team1.mindscape.content.dto.response.content.BookDto; +import likelion.team1.mindscape.content.dto.response.content.MovieDto; +import likelion.team1.mindscape.content.dto.response.content.MusicDto; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HistoryResponse { + private Long testId; + + @JsonProperty("Recommend") + private Recommend recommend; + + @Data + @NoArgsConstructor + @AllArgsConstructor + public static class Recommend { + @JsonProperty("Book") + private List book; + @JsonProperty("Music") + private List music; + @JsonProperty("Movie") + private List movie; + } +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/TestInfoResponse.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/TestInfoResponse.java new file mode 100644 index 0000000..56f28d3 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/TestInfoResponse.java @@ -0,0 +1,19 @@ +package likelion.team1.mindscape.content.dto.response; + +import lombok.Getter; +import lombok.Setter; + +import lombok.Getter; +import lombok.Setter; +import lombok.NoArgsConstructor; +import lombok.AllArgsConstructor; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class TestInfoResponse { + private Long testId; + private Long userId; + private String userType; +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/BookDto.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/BookDto.java new file mode 100644 index 0000000..d2e4066 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/BookDto.java @@ -0,0 +1,27 @@ +package likelion.team1.mindscape.content.dto.response.content; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class BookDto { + private String title; + + private String author; + + private String description; + + private String image; + + public static BookDto from(BookResponse response) { + return new BookDto( + response.getTitle(), + response.getAuthor(), + response.getDescription(), + response.getImage() + ); + } +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/BookResponse.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/BookResponse.java new file mode 100644 index 0000000..ed88261 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/BookResponse.java @@ -0,0 +1,28 @@ +package likelion.team1.mindscape.content.dto.response.content; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +import java.util.Map; + +@Getter +@AllArgsConstructor +@Builder +@ToString +public class BookResponse { + private String title; + private String author; + private String description; + private String image; + + public static BookResponse fromRedis(Map cached) { + return new BookResponse( + (String) cached.getOrDefault("title", ""), + (String) cached.getOrDefault("author", ""), + (String) cached.getOrDefault("description", ""), + (String) cached.getOrDefault("image", "") + ); + } +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MovieDto.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MovieDto.java new file mode 100644 index 0000000..9e1241c --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MovieDto.java @@ -0,0 +1,46 @@ +package likelion.team1.mindscape.content.dto.response.content; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.Setter; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Map; + +@Getter +@Setter +@AllArgsConstructor +public class MovieDto { + private String title; + + @JsonProperty("release_date") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") + private Date releaseDate; + + @JsonProperty("overview") + private String description; + + @JsonProperty("poster_path") + private String poster; + + public MovieDto() {} + public MovieDto(String title) { + this.title = title; + } + public static MovieDto fromRedisHash(Map hash) { + MovieDto dto = new MovieDto(); + dto.setTitle((String) hash.get("title")); + dto.setDescription((String) hash.get("description")); + dto.setPoster((String) hash.get("poster")); + try { + dto.setReleaseDate(new SimpleDateFormat("yyyy-MM-dd").parse((String) hash.get("release_date"))); + } catch (ParseException e) { + dto.setReleaseDate(null); + } + return dto; + } +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MovieResponse.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MovieResponse.java new file mode 100644 index 0000000..3b3a82c --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MovieResponse.java @@ -0,0 +1,10 @@ +package likelion.team1.mindscape.content.dto.response.content; + +import lombok.Getter; + +import java.util.List; + +@Getter +public class MovieResponse { + private List results; +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MusicDto.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MusicDto.java new file mode 100644 index 0000000..4ae445e --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MusicDto.java @@ -0,0 +1,25 @@ +package likelion.team1.mindscape.content.dto.response.content; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MusicDto { + private String title; + + private String artist; + + private String album; + + public static MusicDto from(MusicResponse response) { + return new MusicDto( + response.getTitle(), + response.getArtist(), + response.getAlbum() + ); + } +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MusicResponse.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MusicResponse.java new file mode 100644 index 0000000..d4928f7 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/dto/response/content/MusicResponse.java @@ -0,0 +1,26 @@ +package likelion.team1.mindscape.content.dto.response.content; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.ToString; + +import java.util.Map; + +@Getter +@AllArgsConstructor +@Builder +@ToString +public class MusicResponse { + private String title; + private String artist; + private String album; // image + + public static MusicResponse fromRedis(Map cached) { + return new MusicResponse( + (String) cached.getOrDefault("title", ""), + (String) cached.getOrDefault("artist", ""), + (String) cached.getOrDefault("album", "") + ); + } +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/Book.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/Book.java new file mode 100644 index 0000000..2a82cec --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/Book.java @@ -0,0 +1,43 @@ +package likelion.team1.mindscape.content.entity; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "book") +@Data +@NoArgsConstructor +public class Book { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long bookId; + + @Column(length = 100, nullable = false) + private String title; + + @Column(length = 100) + private String author; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(length = 255) + private String image; + + @ManyToOne + @JoinColumn(name = "recomId", nullable = false) + private RecomContent recommendedContent; + + + public Book(String title, String author, String description, String image, RecomContent recommendedContent) { + this.title = title; + this.author = author; + this.description = description; + this.image = image; + this.recommendedContent = recommendedContent; + } +} + + diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/Movie.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/Movie.java new file mode 100644 index 0000000..f71ba2c --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/Movie.java @@ -0,0 +1,41 @@ +package likelion.team1.mindscape.content.entity; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Date; + +@Entity +@Data +@NoArgsConstructor +@Table(name="movie") +public class Movie { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long movieId; + + @Column(length = 100, nullable = false) + private String title; + + private Date releaseDate; + + @Column(columnDefinition = "TEXT") + private String description; + + @Column(length = 255) + private String poster; + + @ManyToOne + @JoinColumn(name = "recomId", nullable = false) + private RecomContent recommendedContent; + + public Movie(String title, Date releaseDate, String description, String poster, RecomContent recommendedContent) { + this.title = title; + this.releaseDate = releaseDate; + this.description = description; + this.poster = poster; + this.recommendedContent = recommendedContent; + } + +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/Music.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/Music.java new file mode 100644 index 0000000..dfa06c0 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/Music.java @@ -0,0 +1,38 @@ +package likelion.team1.mindscape.content.entity; + +import java.util.Date; + +import jakarta.persistence.*; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "music") +@Data +@NoArgsConstructor +public class Music { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer musicId; + + @Column(length = 100, nullable = false) + private String title; + + @Column(length = 100) + private String artist; + + @Column(length = 255) + private String elbum; + + @ManyToOne + @JoinColumn(name = "recomId", nullable = false) + private RecomContent recommendedContent; + + public Music(String title, String artist, String elbum, RecomContent recommendedContent) { + this.title = title; + this.artist = artist; + this.elbum = elbum; + this.recommendedContent = recommendedContent; + } +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/RecomContent.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/RecomContent.java new file mode 100644 index 0000000..7fb207f --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/entity/RecomContent.java @@ -0,0 +1,28 @@ +package likelion.team1.mindscape.content.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Data; + +@Entity +@Table(name = "recommended_content") +@AllArgsConstructor +@Data +public class RecomContent { + + @Id + @Column(name = "recom_id") + private Long recomId; + + @Column(name = "test_id", nullable = false) + private Long testId; + + // 기본 생성자 반드시 필요 + protected RecomContent() { + } + + public RecomContent(Long testId) { + this.recomId = testId; + this.testId = testId; + } +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/enums/ContentType.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/enums/ContentType.java new file mode 100644 index 0000000..d395a89 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/enums/ContentType.java @@ -0,0 +1,7 @@ +package likelion.team1.mindscape.content.enums; + +public enum ContentType { + MOVIE, + BOOK, + MUSIC +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/AppConfig.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/AppConfig.java new file mode 100644 index 0000000..37abbab --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/AppConfig.java @@ -0,0 +1,13 @@ +package likelion.team1.mindscape.content.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class AppConfig { + @Bean + public RestTemplate restTemplate(){ + return new RestTemplate(); + } +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/GeminiConfig.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/GeminiConfig.java new file mode 100644 index 0000000..f0599c9 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/GeminiConfig.java @@ -0,0 +1,30 @@ +package likelion.team1.mindscape.content.global.config; + +import com.google.genai.Client; +import lombok.Getter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@Getter +public class GeminiConfig { + + @Value("${gemini.api.key}") + private String apiKey; + + @Value("${gemini.model.name}") + private String modelName; + + @Value("${gemini.prompt.systemInstruction:}") // systemInstruction은 선택적 + private String systemInstruction; + + @Bean + public Client geminiClient() { + return Client.builder() + .apiKey(apiKey) + .build(); + } + + // 1.0.0 버전에는 GenerateContentConfig가 없으므로 해당 빈은 삭제하거나 주석 처리 +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/RedisConfig.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/RedisConfig.java new file mode 100644 index 0000000..45fbef5 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/RedisConfig.java @@ -0,0 +1,42 @@ +package likelion.team1.mindscape.content.global.config; + +import lombok.AllArgsConstructor; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +@AllArgsConstructor +@EnableConfigurationProperties({RedisConfigProperties.class}) +public class RedisConfig { + private final RedisConfigProperties redisprops; + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + RedisStandaloneConfiguration config = new RedisStandaloneConfiguration(redisprops.getHost(), redisprops.getPort()); + config.setPassword(RedisPassword.of(redisprops.getPassword())); + return new LettuceConnectionFactory(config); + } + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory redisConnectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory); + redisTemplate.setKeySerializer(new StringRedisSerializer()); // key -> string + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); // value → JSON 직렬화 + redisTemplate.setHashKeySerializer(new StringRedisSerializer()); + redisTemplate.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } + + +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/RedisConfigProperties.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/RedisConfigProperties.java new file mode 100644 index 0000000..86beef7 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/config/RedisConfigProperties.java @@ -0,0 +1,19 @@ +package likelion.team1.mindscape.content.global.config; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@AllArgsConstructor +@ConfigurationProperties(prefix = "spring.data.redis") +public class RedisConfigProperties { + + private String host; + private Integer port; + private String password; +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/security/AESUtil.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/security/AESUtil.java new file mode 100644 index 0000000..cfc9a4f --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/global/security/AESUtil.java @@ -0,0 +1,37 @@ +package likelion.team1.mindscape.content.global.security; + +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; + +@Component +@RequiredArgsConstructor +public class AESUtil { + + private static final String ALGORITHM = "AES"; + + @Value("${server.security.encryptKey}") + private String encryptKey; + + public String encrypt(String input) throws Exception { + SecretKeySpec keySpec = new SecretKeySpec(encryptKey.getBytes(), ALGORITHM); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.ENCRYPT_MODE, keySpec); + byte[] encryptedBytes = cipher.doFinal(input.getBytes()); + return Base64.getEncoder().encodeToString(encryptedBytes); + } + + public String decrypt(String encryptedInput) throws Exception { + SecretKeySpec keySpec = new SecretKeySpec(encryptKey.getBytes(), ALGORITHM); + Cipher cipher = Cipher.getInstance(ALGORITHM); + cipher.init(Cipher.DECRYPT_MODE, keySpec); + byte[] encryptedBytes = Base64.getDecoder().decode(encryptedInput); + byte[] decryptedBytes = cipher.doFinal(encryptedBytes); + return new String(decryptedBytes); + } + +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/BookRepository.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/BookRepository.java new file mode 100644 index 0000000..49bbed4 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/BookRepository.java @@ -0,0 +1,20 @@ +package likelion.team1.mindscape.content.repository; + +import likelion.team1.mindscape.content.entity.Book; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface BookRepository extends JpaRepository { + Optional findByTitle(String title); + + List findTop3AllByRecommendedContent_RecomId(Long recommendedContentRecomId); + + @Query(value = "SELECT b.title, COUNT(*) as cnt, GROUP_CONCAT(DISTINCT b.book_id ORDER BY b.book_id ASC SEPARATOR ',') as book_ids " + + "FROM book b WHERE b.recom_id IN :recomIds GROUP BY b.title ORDER BY cnt DESC LIMIT :limit", nativeQuery = true) + List getBookCountWithIds(List recomIds, int limit); + + Book getBookByBookId(Long bookId); +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/MovieRepository.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/MovieRepository.java new file mode 100644 index 0000000..10ae1e8 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/MovieRepository.java @@ -0,0 +1,21 @@ +package likelion.team1.mindscape.content.repository; + +import likelion.team1.mindscape.content.entity.Movie; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface MovieRepository extends JpaRepository { + List findByTitle(String title); + + List findTop3AllByRecommendedContent_RecomId(Long recomId); + + @Query(value = "SELECT m.title, COUNT(*) as cnt, GROUP_CONCAT(DISTINCT m.movie_id ORDER BY m.movie_id ASC SEPARATOR ',') as movie_ids " + + "FROM movie m WHERE m.recom_id IN :recomIds GROUP BY m.title ORDER BY cnt DESC LIMIT :limit", nativeQuery = true) + List getMovieCountWithIds(List recomIds, int limit); + + Movie getMovieByMovieId(Long movieId); +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/MusicRepository.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/MusicRepository.java new file mode 100644 index 0000000..270db16 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/MusicRepository.java @@ -0,0 +1,20 @@ +package likelion.team1.mindscape.content.repository; + +import likelion.team1.mindscape.content.entity.Music; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; + +import java.util.List; +import java.util.Optional; + +public interface MusicRepository extends JpaRepository { + Optional findByTitleAndArtist(String title, String artist); + + List findTop3AllByRecommendedContent_RecomId(Long recommendedContentRecomId); + + @Query(value = "SELECT m.title, COUNT(*) as cnt, GROUP_CONCAT(DISTINCT m.music_id ORDER BY m.music_id ASC SEPARATOR ',') as music_ids " + + "FROM music m WHERE m.recom_id IN :recomIds GROUP BY m.title ORDER BY cnt DESC LIMIT :limit", nativeQuery = true) + List getMusicCountWithIds(List recomIds, int limit); + + Music getMusicByMusicId(Integer musicId); +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/RecomContentRepository.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/RecomContentRepository.java new file mode 100644 index 0000000..cd7e9bc --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/repository/RecomContentRepository.java @@ -0,0 +1,26 @@ +package likelion.team1.mindscape.content.repository; + +import likelion.team1.mindscape.content.entity.RecomContent; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface RecomContentRepository extends JpaRepository { + /* + TODO: 임의로 mj_test 테이블 생성함 -:> test로 변경! + */ + @Query(value = "SELECT rc.* FROM recommended_content rc " + + "JOIN mj_test t ON rc.test_id = t.testId " + + "WHERE t.user_id = :userId " + + "ORDER BY t.created_at DESC LIMIT 1", nativeQuery = true) + Optional findLatestByUserId(@Param("userId") Long userId); + + + @Query(value = "SELECT * " + + "FROM recommended_content " + + "WHERE test_id = :testId", nativeQuery = true) + List findByTestIdNative(@Param("testId") Long testId); +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/BookService.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/BookService.java new file mode 100644 index 0000000..aa7810b --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/BookService.java @@ -0,0 +1,357 @@ +package likelion.team1.mindscape.content.service; + +import jakarta.transaction.Transactional; +import likelion.team1.mindscape.content.client.TestServiceClient; +import likelion.team1.mindscape.content.dto.response.TestInfoResponse; +import likelion.team1.mindscape.content.dto.response.content.BookDto; +import likelion.team1.mindscape.content.dto.response.content.BookResponse; +import likelion.team1.mindscape.content.entity.Book; +import likelion.team1.mindscape.content.repository.BookRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONArray; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class BookService { + private static final String KAKAO_BOOK_API_BASE = "https://dapi.kakao.com/v3/search/book?query="; + private static final String REDIS_BOOK_KEY_PREFIX = "book:"; + @Value("${service.api.kakaobooks}") + private String kakaoApi; + + private final BookRepository bookRepository; + private final RedisService redisService; + private final RedisTemplate redisTemplate; + private final ContentService contentService; + private final TestServiceClient testServiceClient; + + /** + * 단건 상세 조회 (외부 API 직접 호출) + * + * @param title + * @return + * @throws IOException + */ + public BookResponse getBookDetail(String title) throws IOException { + return fetchFromKakao(title); + } + + /** + * 입력된 BookDto 리스트를 Redis에 저장 (이미 있으면 스킵) + * + * @param bookList + */ + public void saveBookToRedis(List bookList) { + if (bookList == null || bookList.isEmpty()) { + throw new IllegalArgumentException("book list is empty(Redis)"); + } + + bookList.stream() + .filter(dto -> !hasRedisHash(makeRedisKey(dto.getTitle()))) + .forEach(dto -> { + log.info("[REDIS@BookService.saveBookToRedis] Save movie '{}'", dto.getTitle()); + Long id = redisService.BookToRedis(dto); + log.info("'{}' : redis에 저장 완료 (id={})", dto.getTitle(), id); + }); + } + + /** + * testId(recomId)로 저장된 책 목록을 가져온 뒤, + * 각각의 책에 대해 Redis -> Kakao API -> Redis 대체 순으로 정보를 보강 + * + * @param testId + * @return + * @throws IOException + */ + @Transactional + public List getBooksWithTestId(Long testId) throws IOException { + TestInfoResponse testInfo = testServiceClient.getTestInfo(testId); + Long userId = testInfo.getUserId(); + Long recomId = testId; // testId = recomId + + log.info("[SQL@BookService.getBooksWithTestId] Find books by recomId={}", recomId); + List books = bookRepository.findTop3AllByRecommendedContent_RecomId(recomId); + if (books.isEmpty()) { + return books; + } + + // 중복 방지를 위한 이미 사용된 타이틀 모음 + Set usedTitles = books.stream() + .map(Book::getTitle) + .collect(Collectors.toCollection(HashSet::new)); + + List toSave = new ArrayList<>(); + for (Book book : books) { + BookResponse info = resolveBookInfo(book.getTitle(), usedTitles); + if (info == null) { + log.warn("[featch@BookService.getBooksWithTestId] Returned nothing and no alternative found in Redis: {}", book.getTitle()); + continue; + } + applyBookInfo(book, info); + toSave.add(book); + } + log.info("[SQL@BookService.getBooksWithTestId] Save {} books", toSave.size()); + List saved = bookRepository.saveAll(toSave); + List finalTitles = saved.stream().map(Book::getTitle).toList(); + contentService.saveRecomContent(userId, testId, "book", finalTitles); + + return saved; + } + + /** + * Helper + * Redis -> Kakao API -> Redis 대체 순으로 BookResponse를 획득 + * + * @param title + * @param usedTitles + * @return + * @throws IOException + */ + private BookResponse resolveBookInfo(String title, Set usedTitles) throws IOException { + // 1. Redis 조회 + Optional cached = getFromRedis(title); + if (cached.isPresent()) { + BookResponse cachedInfo = cached.get(); + if (isComplete(cachedInfo)) { + log.info("[REDIS@BookService.resolveBookInfo] Found book '{}'", title); + return cachedInfo; + } else { + log.warn("[REDIS@BookService.resolveBookInfo] Incomplete data '{}'. Refresh via API", title); + } + } + + BookResponse info = fetchFromKakao(title); + + // 3. Kakao 실패 또는 불완전 데이터 시 Redis에서 대체 찾기 + if (!isComplete(info)) { + if (info == null) { + log.warn("[KAKAO API@BookService.resolveBookInfo] Failed for '{}'. Get alternative from Redis", title); + } else { + log.warn("[KAKAO API@BookService.resolveBookInfo] Incomplete BookResponse '{}'. Get alternative from Redis", title); + } + info = redisService.getAlternativeBook(new ArrayList<>(usedTitles)); + if (!isComplete(info)) { + log.error("[REDIS@BookService.resolveBookInfo] No alternative found in Redis for '{}'", title); + return null; + } + log.info("[REDIS@BookService.resolveBookInfo] Use alternative '{}'", info.getTitle()); + } else { + cacheToRedis(info); + } + + usedTitles.add(info.getTitle()); + return info; + } + + /** + * Helper + * Kakao API 호출 + * + * @param title + * @return + * @throws IOException + */ + private BookResponse fetchFromKakao(String title) throws IOException { + log.info("[KAKAO API@BookService.fetchFromKakao] Kakao API used for title='{}'", title); + + String query = URLEncoder.encode(title, StandardCharsets.UTF_8); + URL url = new URL(KAKAO_BOOK_API_BASE + query); + + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod("GET"); + connection.setRequestProperty("Authorization", "KakaoAK " + kakaoApi); + + int responseCode = connection.getResponseCode(); + String body = readBody(connection, responseCode == 200); + connection.disconnect(); + + JSONObject bookJson = new JSONObject(body); + JSONArray docs = bookJson.optJSONArray("documents"); + if (docs == null || docs.length() == 0) { + log.warn("[KAKAO API@BookService.fetchFromKakao] Kakao API returned no results for title='{}'", title); + return null; + } + + JSONObject first = docs.getJSONObject(0); + return parseBookResponse(first, title); + } + + /** + * Helper + * JSONArrary로 이루어진 Authors를 String으로 변환 + * + * @param authorsArray + * @return + */ + private String joinAuthors(JSONArray authorsArray) { + if (authorsArray == null || authorsArray.length() == 0) { + return ""; + } + return authorsArray.toList() + .stream() + .map(Object::toString) + .collect(Collectors.joining(", ")); + } + + /** + * Helper + * 기존 Book 객체를 새로운 BookResponse정보로 교체 + * + * @param book + * @param info + */ + private void applyBookInfo(Book book, BookResponse info) { + book.setTitle(info.getTitle()); + book.setAuthor(info.getAuthor()); + book.setDescription(info.getDescription()); + book.setImage(info.getImage()); + } + + /** + * Helper + * Redis에 key 있는지 조회, 있으면 True + * + * @param key + * @return + */ + private boolean hasRedisHash(String key) { + try { + log.info("[REDIS@BookService.hasRedisHash] Check key type {}", key); + String type = redisTemplate.type(key).code(); + if (!"hash".equals(type) && !"none".equals(type)) { + log.warn("[REDIS@BookService.hasRedisHash] Key {} is not hash type: {}", key, type); + return false; + } + if ("none".equals(type)) { + return false; + } + log.info("[REDIS@BookService.hasRedisHash] Get hash size {}", key); + Long size = redisTemplate.opsForHash().size(key); + return size != null && size > 0; + } catch (Exception e) { + log.error("[REDIS@BookService.hasRedisHash] Error checking key {}: {}", key, e.getMessage()); + return false; + } + } + + /** + * Helper + * Redis 키 생성 + * + * @param title + * @return + */ + private String makeRedisKey(String title) { + return REDIS_BOOK_KEY_PREFIX + title; + } + + /** + * Helper + * Redis에서 title로 정보 가져오기 + * + * @param title + * @return + */ + private Optional getFromRedis(String title) { + String key = makeRedisKey(title); + try { + log.info("[REDIS@BookService.getFromRedis] Check key type {}", key); + // 키 타입 확인 + String type = redisTemplate.type(key).code(); + if (!"hash".equals(type)) { + log.debug("[REDIS@BookService.getFromRedis] Key {} is not hash type: {}", key, type); + return Optional.empty(); + } + log.info("[REDIS@BookService.getFromRedis] Load hash {}", key); + Map map = redisTemplate.opsForHash().entries(key); + if (map == null || map.isEmpty()) { + return Optional.empty(); + } + return Optional.of(BookResponse.fromRedis(map)); + } catch (Exception e) { + log.error("[REDIS@BookService.getFromRedis] Error getting book with key {}: {}", key, e.getMessage()); + return Optional.empty(); + } + } + + /** + * Helper + * Redis에 새로운 정보 저장 + * + * @param info + */ + private void cacheToRedis(BookResponse info) { + log.info("[REDIS@BookService.cacheToRedis] Save book to Redis title='{}'", info.getTitle()); + redisService.BookToRedis(new BookDto(info.getTitle(), info.getAuthor(), info.getDescription(), info.getImage())); + } + + /** + * Helper + * Kakao/Redis에서 얻은 BookResponse가 모든 필드를 채웠는지 검증 + * + * @param info + * @return + */ + private boolean isComplete(BookResponse info) { + return info != null + && notNullOrBlank(info.getTitle()) + && notNullOrBlank(info.getAuthor()) + && notNullOrBlank(info.getDescription()) + && notNullOrBlank(info.getImage()); + } + private boolean notNullOrBlank(String s) { + return s != null && !s.trim().isEmpty(); + } + + /** + * HttpURLConnection 응답 본문 읽기 + * + * @param connection + * @param successStream + * @return + * @throws IOException + */ + private String readBody(HttpURLConnection connection, boolean successStream) throws IOException { + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader( + successStream ? connection.getInputStream() : connection.getErrorStream(), + StandardCharsets.UTF_8 + ))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + } + return sb.toString(); + } + + /** + * Kakao 응답 JSON을 BookResponse로 변환 + * + * @param book + * @param fallbackTitle + * @return + */ + private BookResponse parseBookResponse(JSONObject book, String fallbackTitle) { + String authors = joinAuthors(book.optJSONArray("authors")); + return new BookResponse( + book.optString("title", fallbackTitle), + authors, + book.optString("contents", ""), + book.optString("thumbnail", "") + ); + } +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/ContentService.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/ContentService.java new file mode 100644 index 0000000..8f9dc8f --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/ContentService.java @@ -0,0 +1,34 @@ +package likelion.team1.mindscape.content.service; +import likelion.team1.mindscape.content.client.TestServiceClient; +import likelion.team1.mindscape.content.dto.response.GeminiResponse; +import likelion.team1.mindscape.content.dto.response.TestInfoResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class ContentService { + private final RedisTemplate redisTemplate; + private final TestServiceClient testServiceClient; + private final RedisService redisService; + + // 1) 콘텐츠 저장 +// public void saveAllRecomContent(Long testId, GeminiResponse response) { +// TestInfoResponse testInfo = testServiceClient.getTestInfo(testId); +// Long userId = testInfo.getUserId(); +// saveRecomContent(userId, testId, "movie", response.getMovie()); +// saveRecomContent(userId, testId, "book", response.getBook()); +// saveRecomContent(userId, testId, "music", response.getMusic()); +// } + public void saveRecomContent(Long userId, Long testId, String contentType, List titles) { + String redisKey = redisService.makeRecomKey(userId, testId, contentType); + redisTemplate.delete(redisKey); // 기존 데이터 제거 (덮어쓰기) + redisTemplate.opsForList().rightPushAll(redisKey, titles.toArray()); + } +} + diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/GeminiService.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/GeminiService.java new file mode 100644 index 0000000..f33554f --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/GeminiService.java @@ -0,0 +1,68 @@ +package likelion.team1.mindscape.content.service; + +import likelion.team1.mindscape.content.client.GeminiApiClient; +import likelion.team1.mindscape.content.client.TestServiceClient; +import likelion.team1.mindscape.content.dto.response.GeminiResponse; +import likelion.team1.mindscape.content.dto.response.TestInfoResponse; +import likelion.team1.mindscape.content.entity.Book; +import likelion.team1.mindscape.content.entity.Movie; +import likelion.team1.mindscape.content.entity.Music; +import likelion.team1.mindscape.content.entity.RecomContent; +import likelion.team1.mindscape.content.repository.BookRepository; +import likelion.team1.mindscape.content.repository.MovieRepository; +import likelion.team1.mindscape.content.repository.MusicRepository; +import likelion.team1.mindscape.content.repository.RecomContentRepository; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@RequiredArgsConstructor +@Service +public class GeminiService { + private final TestServiceClient testServiceClient; + private final GeminiApiClient geminiApiClient; + private final BookRepository bookRepository; + private final MovieRepository movieRepository; + private final MusicRepository musicRepository; + private final RecomContentRepository recomContentRepository; + + public GeminiResponse recommend(Long testId) { + //--------------------------------------------- + // 1. TestServiceClient를 통해 실제 또는 임시 데이터 받기 + //나중에 변경 + TestInfoResponse testInfo = testServiceClient.getTestInfo(testId); + + String userType = testInfo.getUserType(); + + String prompt = String.format( + "사용자의 성향은 DISC검사 중 %s 입니다. 이 성향에 맞는 영화 3개, 책 3개, 음악 3개를 추천해줘. 아래 JSON 형식으로 출력해주세요. 답변은 json만 주세요:\n" + + "{\n \"movie\": [\"제목1\", \"제목2\", \"제목3\"],\n \"book\": [\"제목1\", \"제목2\", \"제목3\"],\n \"music\": [\"가수 - 제목1\", \"가수 - 제목2\", \"가수 - 제목3\"]\n}", + userType + ); + +// String prompt = String.format( +// "사용자의 성향은 D.I.S.C 검사 중 랜덤으로 1개를 골라서, 골라준 성향에 맞는 영화 3개, 책 3개, 음악 3개를 추천해줘. 아래 JSON 형식으로 출력해주세요. 답변은 json만 주세요:\n" + +// "{\n \"movie\": [\"제목1\", \"제목2\", \"제목3\"],\n \"book\": [\"제목1\", \"제목2\", \"제목3\"],\n \"music\": [\"가수 - 제목1\", \"가수 - 제목2\", \"가수 - 제목3\"]\n}", +// userType +// ); + + GeminiResponse geminiResponse = geminiApiClient.getRecommendations(prompt); + + RecomContent savedRecom = recomContentRepository.save(new RecomContent(testId)); + + for (String title : geminiResponse.getBook()) { + bookRepository.save(new Book(title, null, null, null, savedRecom)); + } + for (String title : geminiResponse.getMovie()) { + movieRepository.save(new Movie(title, null, null, null, savedRecom)); + } + for (String title : geminiResponse.getMusic()) { + String[] tmp = title.split("[–-]", 2); + if (tmp.length == 2) { + musicRepository.save(new Music(tmp[1].trim(), tmp[0].trim(), null, savedRecom)); + } + } + return geminiResponse; + } + +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/MovieService.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/MovieService.java new file mode 100644 index 0000000..26ee591 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/MovieService.java @@ -0,0 +1,195 @@ +package likelion.team1.mindscape.content.service; + +import jakarta.transaction.Transactional; +import likelion.team1.mindscape.content.client.TestServiceClient; +import likelion.team1.mindscape.content.dto.response.TestInfoResponse; +import likelion.team1.mindscape.content.dto.response.content.MovieDto; +import likelion.team1.mindscape.content.dto.response.content.MovieResponse; +import likelion.team1.mindscape.content.entity.Movie; +import likelion.team1.mindscape.content.entity.RecomContent; +import likelion.team1.mindscape.content.repository.MovieRepository; +import likelion.team1.mindscape.content.repository.RecomContentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriUtils; + +import javax.swing.text.html.Option; +import java.nio.charset.StandardCharsets; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.logging.SimpleFormatter; +import java.util.stream.Collectors; + +import static likelion.team1.mindscape.content.dto.response.content.MovieDto.fromRedisHash; + +@Service +@RequiredArgsConstructor +public class MovieService { + + @Value("${TMDB_API_KEY}") + private String apiKey; + private final RedisService redisService; + private final RestTemplate restTemplate; + private final RedisTemplate redisTemplate; + private final MovieRepository movieRepository; + private final RecomContentRepository recomContentRepository; + private final ContentService contentService; + private final TestServiceClient testServiceClient; + + + public List updateMovieFromTitle(Long testId) { + + TestInfoResponse testInfo = testServiceClient.getTestInfo(testId); + Long userId = testInfo.getUserId(); + List targetMovies = movieRepository.findTop3AllByRecommendedContent_RecomId(testId); + List updatedList = new ArrayList<>(); + boolean isFirstRequest = true; + + // 1. movie 검색 + for (Movie movie : targetMovies) { + if (hasCompleteInfo(movie)) { // 영화 title에 따른 내용 넣기 + updateRecomOnly(movie, testId); + updatedList.add(movieRepository.save(movie)); + } else { + List movieInfo = getMovieInfo(movie.getTitle()); + MovieDto dto = getMovieDtoFallback(movieInfo, movie, isFirstRequest); + if (movieInfo.isEmpty()) { + isFirstRequest = false; // fallback 시도 후에 false 처리 + } + if (dto == null) { + System.out.println("TMDB & Redis 모두 실패: " + movie.getTitle()); + continue; + } + Movie updated = createNewMovie(movie, dto, testId); + updatedList.add(updated); + if (!movieInfo.isEmpty()) { + saveMovieToRedis(movieInfo); + } + } + } + List titles = updatedList.stream() + .map(Movie::getTitle) + .collect(Collectors.toList()); + contentService.saveRecomContent(userId, testId, "movie", titles); + return updatedList; + } + + private MovieDto getMovieDtoFallback(List movieInfo, Movie movie, boolean isFirstRequest) { + if (movieInfo.isEmpty()) { + if (isFirstRequest) { + Optional fallback = getRandomMovie(Collections.singletonList(new MovieDto(movie.getTitle()))); + fallback.ifPresent(dto -> + System.out.println("Redis 대체 영화 사용: " + dto.getTitle()) + ); + return fallback.orElse(null); + } else { return null; } + } else { + saveMovieToRedis(movieInfo); + return movieInfo.get(0); + } + } + + + + /** + * tmdb api에 없는 영화: redis에서 rndm하게 아무 영화 넣기 + * @param movieList + * @return + */ + //TODO: Redis에서 가져올 때 추천받은 title과 중복되는지 확인하는 작업 필요 + public Optional fillMovieInfo(List movieList){ + for(Movie movie: movieList){ + if(!hasCompleteInfo(movie)){ + MovieDto tempDto = new MovieDto(movie.getTitle()); + Optional fallback = getRandomMovie(Collections.singletonList(tempDto)); + return fallback; + } + } + return Optional.empty(); + } + + public List getMovieInfo(String query){ + String url = "https://api.themoviedb.org/3/search/movie" + + "?api_key=" + apiKey + + "&query=" + UriUtils.encode(query, StandardCharsets.UTF_8) + + "&language=ko®ion=KR"; + MovieResponse response = restTemplate.getForObject(url, MovieResponse.class); + return response != null ? response.getResults() : new ArrayList<>(); + } + + /** + * Mysql: 영화 정보 저장 + * movie -> title, desc.. 정보 모두 있음: updateRecomOnly, 정보 없음: createNewMovie + * + */ + private boolean hasCompleteInfo(Movie movie) { + return movie.getDescription() != null && movie.getReleaseDate() != null && movie.getPoster() != null; + } + private void updateRecomOnly(Movie movie, Long testId) { + RecomContent recom = getLatestRecom(testId); + movie.setRecommendedContent(recom); + } + private RecomContent getLatestRecom(Long testId) { + return recomContentRepository + .findByTestIdNative(testId) + .stream() + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("추천 결과가 없습니다.")); + } + + private Movie createNewMovie(Movie movie, MovieDto dto, Long userId){ + movie.setTitle(dto.getTitle()); + movie.setDescription(dto.getDescription()); + movie.setReleaseDate(dto.getReleaseDate()); + movie.setPoster("http://image.tmdb.org/t/p/w500"+dto.getPoster()); + movie.setRecommendedContent(getLatestRecom(userId)); + return movieRepository.save(movie); + } + /** + * Redis 영화 저장 + */ + public void saveMovieToRedis(List movieList){ + if(movieList == null || movieList.isEmpty()){ + throw new IllegalArgumentException("movie list is empty(Redis)"); + } + MovieDto dto = movieList.get(0); + String searchPattern = "movie:*"+dto.getTitle(); + Set keys = redisTemplate.keys(searchPattern); + if (keys != null && !keys.isEmpty()) { + System.out.println(dto.getTitle() + ": redis에 이미 존재"); + return; + } + // redis 저장 + Long id = redisService.MovieToRedis(dto); + System.out.println(dto.getTitle() + ": redis에 저장 완료 (id=" + id + ")"); + } + public Optional getRandomMovie (List movieInfo){ + // 기존 영화 제목 + Set existingTitles = movieInfo.stream() + .map(MovieDto::getTitle) + .collect(Collectors.toSet()); + // redis 영화제목 + Set keys = redisTemplate.keys("movie:*"); + if(keys == null || keys.isEmpty()) return Optional.empty(); + + List shuffledKeys = new ArrayList<>(keys); + Collections.shuffle(shuffledKeys); + + for(String key: shuffledKeys){ + String title = key.replace("movie:",""); + if(existingTitles.contains(title)) continue; + Map hash = redisTemplate.opsForHash().entries(key); + MovieDto dto = fromRedisHash(hash); // Dto로 변환 + return Optional.of(dto); + } + return Optional.empty(); + } +} + + + + diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/MusicService.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/MusicService.java new file mode 100644 index 0000000..d4fb4f3 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/MusicService.java @@ -0,0 +1,361 @@ +package likelion.team1.mindscape.content.service; + +import likelion.team1.mindscape.content.client.TestServiceClient; +import likelion.team1.mindscape.content.dto.response.TestInfoResponse; +import likelion.team1.mindscape.content.dto.response.content.MusicDto; +import likelion.team1.mindscape.content.dto.response.content.MusicResponse; +import likelion.team1.mindscape.content.entity.Music; +import likelion.team1.mindscape.content.repository.MusicRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.json.JSONArray; +import org.json.JSONObject; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MusicService { + private static final String LASTFM_API_BASE = "http://ws.audioscrobbler.com/2.0/?method=track.getInfo&format=json&api_key=%s&artist=%s&track=%s"; + private static final String REDIS_MUSIC_KEY_PREFIX = "music:"; + @Value("${service.api.lastfm}") + private String lastfmApi; + + private final MusicRepository musicRepository; + private final RedisTemplate redisTemplate; + private final RedisService redisService; + private final ContentService contentService; + private final TestServiceClient testServiceClient; + + + /** + * 단건 상세 조회 (외부 API 직접 호출) + * + * @param artist + * @param title + * @return + * @throws IOException + */ + public MusicResponse getMusicDetail(String artist, String title) throws IOException { + return fetchFromLastfm(artist, title); + } + + /** + * 입력된 MusicDto 리스트를 Redis에 저장 (이미 있으면 스킵) + * + * @param musicList + */ + public void saveMusicToRedis(List musicList) { + if (musicList == null || musicList.isEmpty()) { + throw new IllegalArgumentException("music list is empty(Redis)"); + } + + musicList.stream() + .filter(dto -> !hasRedisHash(makeRedisKey(dto.getTitle()))) + .forEach(dto -> { + log.info("[REDIS@MusicService.saveMusicToRedis] Save music '{}'", dto.getTitle()); + Long id = redisService.MusicToRedis(dto); + log.info("'{}' : redis에 저장 완료 (id={})", dto.getTitle(), id); + }); + } + + /** + * testId(recomId)로 저장된 책 목록을 가져온 뒤, + * 각각의 책에 대해 Redis -> LastFM API -> Redis 대체 순으로 정보를 보강 + * + * @param testId + * @return + * @throws IOException + */ + public List getMusicWithTestId(Long testId) throws IOException { + TestInfoResponse testInfo = testServiceClient.getTestInfo(testId); + Long userId = testInfo.getUserId(); + Long recomId = testId; // testId = recomId + + log.info("[SQL@MusicService.getMusicWithTestId] Find music by recomId={}", recomId); + List musics = musicRepository.findTop3AllByRecommendedContent_RecomId(recomId); + if (musics.isEmpty()) { + return musics; + } + + // 중복 방지를 위해 이미 사용된 타이틀 모음 + Set usedTitles = musics.stream() + .map(Music::getTitle) + .collect(Collectors.toCollection(HashSet::new)); + + List toSave = new ArrayList<>(); + for (Music music : musics) { + MusicResponse info = resolveMusicInfo(music.getArtist(), music.getTitle(), usedTitles); + if (info == null) { + log.warn("[fetch@MusicService.getMusicWithTestId] Returned nothing and no alternative found in Redis: {}", music.getTitle()); + continue; + } + applyMusicInfo(music, info); + toSave.add(music); + } + log.info("[SQL@MusicService.getMusicWithTestId] Save {} music", toSave.size()); + List saved = musicRepository.saveAll(toSave); + List titles = saved.stream().map(Music::getTitle).toList(); + contentService.saveRecomContent(userId, testId, "music", titles); + return saved; + } + + /** + * Helper + * Redis -> LastFm API -> Redis 대체 순으로 MusicResponse 획득 + * + * @param artist + * @param title + * @param usedTitles + * @return + * @throws IOException + */ + private MusicResponse resolveMusicInfo(String artist, String title, Set usedTitles) throws IOException { + // 1) Redis 조회 + Optional cached = getFromRedis(title); + if (cached.isPresent()) { + MusicResponse cachedInfo = cached.get(); + if (isComplete(cachedInfo)) { + log.info("[REDIS@MusicService.resolveMusicInfo] Found music '{}'", title); + return cachedInfo; + } else { + log.warn("[REDIS@MusicService.resolveMusicInfo] Incomplete data '{}', Refresh via API", title); + } + } + + // 2) LastFM API 조회 + MusicResponse info = fetchFromLastfm(artist, title); + + // 3) LastFM 실패 시 Redis에서 대체 찾기 + if (!isComplete(info)) { + if (info == null) { + log.warn("[LASTFM API@MusicService.resolveMusicInfo] Failed for '{}'. Get alternative from Redis", title); + } else { + log.warn("[LASTFM API@MusicService.resolveMusicInfo] Incomplete MusicResponse '{}'. Get alternative from Redis", title); + } + info = redisService.getAlternativeMusic(new ArrayList<>(usedTitles)); + if (!isComplete(info)) { + log.error("[REDIS@MusicService.resolveMusicInfo] No alternative found in Redis for '{}'", title); + return null; + } + log.info("[REDIS@MusicService.resolveMusicInfo] Use alternative '{}'", info.getTitle()); + } else { + cacheToRedis(info); + } + + usedTitles.add(info.getTitle()); + return info; + } + + /** + * Helper + * LastFM API 호출 + * + * @param artist + * @param title + * @return + * @throws IOException + */ + private MusicResponse fetchFromLastfm(String artist, String title) throws IOException { + log.info("[LASTFM API@MusicService.fetchFromLastfm] LastFM API used for artist = '{}', title='{}'", artist, title); + + String artistQuery = URLEncoder.encode(artist, StandardCharsets.UTF_8); + String titleQuery = URLEncoder.encode(title, StandardCharsets.UTF_8); + String apiURL = String.format(LASTFM_API_BASE, lastfmApi, artistQuery, titleQuery); + + HttpURLConnection connection = (HttpURLConnection) new URL(apiURL).openConnection(); + connection.setRequestMethod("GET"); + + int responseCode = connection.getResponseCode(); + String body = readBody(connection, responseCode == 200); + connection.disconnect(); + + JSONObject musicJson = new JSONObject(body); + JSONObject musicInfo = musicJson.optJSONObject("track"); + if (musicInfo == null || musicInfo.length() == 0) { + log.warn("[LASTFM API@MusicService.fetchFromLastfm] LastFM API returned no results for artist = '{}', title='{}'", artist, title); + return null; + } + + return parseMusicResponse(musicInfo, artist, title); + } + + /** + * Helper + * 기존 Music 객체를 새로운 MusicResponse정보로 교체 + * + * @param music + * @param info + */ + private void applyMusicInfo(Music music, MusicResponse info) { + music.setTitle(info.getTitle()); + music.setArtist(info.getArtist()); + music.setElbum(info.getAlbum()); + } + + /** + * Helper + * Redis에 key 있는지 조회, 있으면 True + * + * @param key + * @return + */ + private boolean hasRedisHash(String key) { + try { + log.info("[REDIS@MusicService.hasRedisHash] Check key type {}", key); + String type = redisTemplate.type(key).code(); + if (!"hash".equals(type) && !"none".equals(type)) { + log.warn("[REDIS@MusicService.hasRedisHash] Key {} is not hash type: {}", key, type); + return false; + } + if ("none".equals(type)) { + return false; + } + log.info("[REDIS@MusicService.hasRedisHash] Get hash size {}", key); + Long size = redisTemplate.opsForHash().size(key); + return size != null && size > 0; + } catch (Exception e) { + log.error("[REDIS@MusicService.hasRedisHash] Error checking key {}: {}", key, e.getMessage()); + return false; + } + } + + /** + * Helper + * Redis 키 생성 + * + * @param title + * @return + */ + private String makeRedisKey(String title) { + return REDIS_MUSIC_KEY_PREFIX + title; + } + + /** + * Helper + * Redis에서 title로 정보 가져오기 + * + * @param title + * @return + */ + private Optional getFromRedis(String title) { + String key = makeRedisKey(title); + try { + log.info("[REDIS@MusicService.getFromRedis] Check key type {}", key); + // 키 타입 확인 + String type = redisTemplate.type(key).code(); + if (!"hash".equals(type)) { + log.debug("[REDIS@MusicService.getFromRedis] Key {} is not hash type: {}", key, type); + return Optional.empty(); + } + log.info("[REDIS@MusicService.getFromRedis] Load hash {}", key); + Map map = redisTemplate.opsForHash().entries(key); + if (map == null || map.isEmpty()) { + return Optional.empty(); + } + return Optional.of(MusicResponse.fromRedis(map)); + } catch (Exception e) { + log.error("[REDIS@MusicService.getFromRedis] Error getting music with key {}: {}", key, e.getMessage()); + return Optional.empty(); + } + } + + /** + * Helper + * Redis에 새로운 정보 저장 + * + * @param info + */ + private void cacheToRedis(MusicResponse info) { + log.info("[REDIS@BookService.cacheToRedis] Save music to Redis title='{}'", info.getTitle()); + redisService.MusicToRedis(new MusicDto(info.getTitle(), info.getArtist(), info.getAlbum())); + } + + /** + * Helper + * LastFM/Redis에서 얻은 BookResponse가 모든 필드를 채웠는지 검증 + * + * @param info + * @return + */ + private boolean isComplete(MusicResponse info) { + return info != null + && notNullOrBlank(info.getTitle()) + && notNullOrBlank(info.getArtist()) + && notNullOrBlank(info.getAlbum()); + } + + private boolean notNullOrBlank(String s) { + return s != null && !s.trim().isEmpty(); + } + + /** + * HttpURLConnection 응답 본문 읽기 + * + * @param connection + * @param successStream + * @return + * @throws IOException + */ + private String readBody(HttpURLConnection connection, boolean successStream) throws IOException { + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader( + successStream ? connection.getInputStream() : connection.getErrorStream(), + StandardCharsets.UTF_8 + ))) { + String line; + while ((line = br.readLine()) != null) { + sb.append(line); + } + } + return sb.toString(); + } + + /** + * Lastfm 응답 JSON을 BookResponse로 변환 + * + * @param musicInfo + * @param fallbackArtist + * @param fallbackTitle + * @return + */ + private MusicResponse parseMusicResponse(JSONObject musicInfo, String fallbackArtist, String fallbackTitle) { + // album image -> 가장 큰 사이즈 선택 + String imageUrl = null; + JSONObject albumObj = musicInfo.optJSONObject("album"); + if (albumObj != null) { + JSONArray images = albumObj.optJSONArray("image"); + if (images != null) { + for (int i = images.length() - 1; i >= 0; i--) { + JSONObject imgObj = images.optJSONObject(i); + if (imgObj != null) { + String img = imgObj.optString("#text", ""); + if (!img.isEmpty()) { + imageUrl = img; + break; + } + } + } + } + } + + String trackName = musicInfo.optString("name", fallbackTitle); + JSONObject artistObj = musicInfo.optJSONObject("artist"); + String artistName = artistObj != null ? artistObj.optString("name", fallbackArtist) : fallbackArtist; + + return new MusicResponse(trackName, artistName, imageUrl); + } + +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/RedisInitializer.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/RedisInitializer.java new file mode 100644 index 0000000..dbb2ba0 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/RedisInitializer.java @@ -0,0 +1,184 @@ +package likelion.team1.mindscape.content.service; + +import likelion.team1.mindscape.content.dto.response.content.BookDto; +import likelion.team1.mindscape.content.dto.response.content.MovieDto; +import likelion.team1.mindscape.content.dto.response.content.MusicDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.Map; + +@Component +@RequiredArgsConstructor +@Slf4j +@ConditionalOnProperty( + name = "app.redis.auto-init.enabled", + havingValue = "true", + matchIfMissing = true // 설정이 없으면 기본적으로 활성화 +) +public class RedisInitializer implements ApplicationRunner { + + private final MovieService movieService; + private final BookService bookService; + private final MusicService musicService; + private final RedisTemplate redisTemplate; + + private final List MOVIESLIST = List.of("쇼생크 탈출", "인셉션", "매트릭스"); + private final List BOOKSLIST = List.of("앵무새 죽이기(리커버판)", "1984", "연금술사"); + private final List MUSICLIST = List.of( + "Queen - Bohemian Rhapsody", + "The Beatles - Let It Be", + "Bob Dylan - Like A Rolling Stone" + ); + + private final int NUMBER_OF_CONTENTS = 3; + + + @Override + public void run(ApplicationArguments args) throws Exception { + log.info("Redis 자동 초기화 시작"); + + if (isRedisDataExists()) { + log.info("Redis에 이미 데이터가 존재하므로 초기화를 건너뜁니다."); + return; + } + + try { + initializeRedisData(); + log.info("Redis 자동 초기화 완료!"); + } catch (Exception e) { + log.error("Redis 자동 초기화 중 오류 발생: {}", e.getMessage(), e); + } + } + + private boolean isRedisDataExists() { + long movieCount = countContentKeys("movie:"); + long bookCount = countContentKeys("book:"); + long musicCount = countContentKeys("music:"); + + boolean hasMovies = movieCount >= NUMBER_OF_CONTENTS; + boolean hasBooks = bookCount >= NUMBER_OF_CONTENTS; + boolean hasMusic = musicCount >= NUMBER_OF_CONTENTS; + + log.info("Redis 데이터 존재 여부 - Movies: {} ({}개), Books: {} ({}개), Music: {} ({}개)", hasMovies, movieCount, hasBooks, bookCount, hasMusic, musicCount); + + return hasMovies && hasBooks && hasMusic; + } + + private long countContentKeys(String prefix) { + Set keys = redisTemplate.keys(prefix + "*"); + if (keys == null || keys.isEmpty()) { + return 0; + } + String idKey = prefix + "id"; + return keys.stream() + .filter(k -> !k.equals(idKey)) + .count(); + } + + + /** + * Redis에 초기 더미 데이터 저장 + */ + private void initializeRedisData() throws IOException { + log.info("Redis 더미 데이터 생성 중..."); + + List movieDtos = new ArrayList<>(); + List musicDtos = new ArrayList<>(); + List bookDtos = new ArrayList<>(); + + // 외부 API에서 상세 정보 가져오기 + try { + for (int i = 0; i < MOVIESLIST.size(); i++) { + // 영화 정보 가져오기 + List movieInfo = movieService.getMovieInfo(MOVIESLIST.get(i)); + if (!movieInfo.isEmpty()) { + movieDtos.add(movieInfo.get(0)); + log.info("영화 정보 가져옴: {}", MOVIESLIST.get(i)); + } else { + log.warn("영화 정보를 찾을 수 없음: {}", MOVIESLIST.get(i)); + } + + // 책 정보 가져오기 + try { + BookDto bookDto = BookDto.from(bookService.getBookDetail(BOOKSLIST.get(i))); + bookDtos.add(bookDto); + log.info("책 정보 가져옴: {}", BOOKSLIST.get(i)); + } catch (Exception e) { + log.warn("책 정보를 가져오는데 실패: {} - {}", BOOKSLIST.get(i), e.getMessage()); + } + + // 음악 정보 가져오기 + String[] tmp = MUSICLIST.get(i).split("[–-]", 2); + if (tmp.length == 2) { + try { + MusicDto musicDto = MusicDto.from( + musicService.getMusicDetail(tmp[0].trim(), tmp[1].trim()) + ); + musicDtos.add(musicDto); + log.info("음악 정보 가져옴: {}", MUSICLIST.get(i)); + } catch (Exception e) { + log.warn("음악 정보를 가져오는데 실패: {} - {}", MUSICLIST.get(i), e.getMessage()); + } + } else { + log.warn("잘못된 음악 형식: {}", MUSICLIST.get(i)); + } + } + } catch (Exception e) { + log.error("외부 API 호출 중 오류 발생", e); + throw e; + } + + // Redis에 데이터 저장 + saveDataToRedis(movieDtos, musicDtos, bookDtos); + } + + /** + * 가져온 데이터를 Redis에 저장 + */ + private void saveDataToRedis(List movieDtos, List musicDtos, List bookDtos) { + // 영화 데이터 저장 + for (MovieDto movieDto : movieDtos) { + try { + movieService.saveMovieToRedis(List.of(movieDto)); + log.info("Redis에 영화 저장: {}", movieDto.getTitle()); + } catch (Exception e) { + log.error("영화 Redis 저장 실패: {} - {}", movieDto.getTitle(), e.getMessage()); + } + } + + // 음악 데이터 저장 + if (!musicDtos.isEmpty()) { + try { + musicService.saveMusicToRedis(musicDtos); + log.info("Redis에 음악 {} 곡 저장 완료", musicDtos.size()); + } catch (Exception e) { + log.error("음악 Redis 저장 실패: {}", e.getMessage()); + } + } + + // 책 데이터 저장 + if (!bookDtos.isEmpty()) { + try { + bookService.saveBookToRedis(bookDtos); + log.info("Redis에 책 {} 권 저장 완료", bookDtos.size()); + } catch (Exception e) { + log.error("책 Redis 저장 실패: {}", e.getMessage()); + } + } + + log.info("Redis 데이터 저장 완료 - 영화: {}, 음악: {}, 책: {}", + movieDtos.size(), musicDtos.size(), bookDtos.size()); + } + +} \ No newline at end of file diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/RedisService.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/RedisService.java new file mode 100644 index 0000000..3442571 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/RedisService.java @@ -0,0 +1,144 @@ +package likelion.team1.mindscape.content.service; +import likelion.team1.mindscape.content.dto.response.content.*; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; +import org.springframework.data.redis.connection.DataType; + +import java.text.SimpleDateFormat; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class RedisService { + private final RedisTemplate redisTemplate; + + public Long MovieToRedis(MovieDto dto) { + Long newId = redisTemplate.opsForValue().increment("movie:id"); + String key = "movie:" +dto.getTitle(); + // date format + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd"); + String releaseDateStr = formatter.format(dto.getReleaseDate()); + // saved as hash + Map movieMap = new HashMap<>(); + movieMap.put("title", dto.getTitle()); + movieMap.put("description", dto.getDescription()); + movieMap.put("poster", "http://image.tmdb.org/t/p/w500" + dto.getPoster()); + movieMap.put("release_date", releaseDateStr); + + redisTemplate.opsForHash().putAll(key, movieMap); + return newId; + } + + public Long MusicToRedis(MusicDto dto) { + Long newId = redisTemplate.opsForValue().increment("music:id"); + // saved as hash + String key = "music:" + dto.getTitle(); + + Map musicMap = new HashMap<>(); + musicMap.put("title", dto.getTitle()); + musicMap.put("artist", dto.getArtist()); + musicMap.put("album", dto.getAlbum()); + + // duplication check + Map existingMap = redisTemplate.opsForHash().entries(key); + boolean isDifferent = existingMap.isEmpty() || !musicMap.equals(existingMap); + + if (isDifferent) { + redisTemplate.opsForHash().putAll(key, musicMap); + } + return newId; + } + + public Long BookToRedis(BookDto dto){ + Long newId = redisTemplate.opsForValue().increment("book:id"); + String key = "book:" + dto.getTitle(); + + Map bookMap = new HashMap<>(); + bookMap.put("title", dto.getTitle()); + bookMap.put("author", dto.getAuthor()); + bookMap.put("description", dto.getDescription()); + bookMap.put("image", dto.getImage()); + + redisTemplate.opsForHash().putAll(key, bookMap); + return newId; + } + + public BookResponse getAlternativeBook(List excludeTitles) { + Set keys = redisTemplate.keys("book:*"); + if (keys == null || keys.isEmpty()) { + return null; + } + for (String key : keys) { + if ("book:id".equals(key)) { + continue; + } + DataType type = redisTemplate.type(key); + if (!DataType.HASH.equals(type)) { + continue; + } + Map map = redisTemplate.opsForHash().entries(key); + if (map == null || map.isEmpty()) { + continue; + } + Object titleObj = map.get("title"); + if (titleObj == null) { + continue; + } + String title = titleObj.toString(); + if (title.isBlank() || excludeTitles.contains(title)) { + continue; + } + return new BookResponse( + title, + (String) map.get("author"), + (String) map.get("description"), + (String) map.get("image") + ); + } + return null; + } + + public MusicResponse getAlternativeMusic(List excludeTitles) { + Set keys = redisTemplate.keys("music:*"); + if (keys == null || keys.isEmpty()) { + return null; + } + for (String key : keys) { + if ("music:id".equals(key)) { + continue; + } + DataType type = redisTemplate.type(key); + if (!DataType.HASH.equals(type)) { + continue; + } + Map map = redisTemplate.opsForHash().entries(key); + if (map == null || map.isEmpty()) { + continue; + } + Object titleObj = map.get("title"); + if (titleObj == null) { + continue; + } + String title = titleObj.toString(); + if (title.isBlank() || excludeTitles.contains(title)) { + continue; + } + return new MusicResponse( + (String) map.get("title"), + (String) map.get("artist"), + (String) map.get("album") + ); + } + return null; + } + + public String makeRecomKey(Long userId, Long testId, String contentType) { + return String.format("user:%d:test:%d:%s", userId, testId, contentType); + } + + +} diff --git a/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/ResponseService.java b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/ResponseService.java new file mode 100644 index 0000000..021c894 --- /dev/null +++ b/mindscape-service/src/main/java/likelion/team1/mindscape/content/service/ResponseService.java @@ -0,0 +1,408 @@ +package likelion.team1.mindscape.content.service; + +import likelion.team1.mindscape.content.dto.ContentCountDto; +import likelion.team1.mindscape.content.dto.response.HistoryResponse; +import likelion.team1.mindscape.content.dto.response.content.BookDto; +import likelion.team1.mindscape.content.dto.response.content.MovieDto; +import likelion.team1.mindscape.content.dto.response.content.MusicDto; +import likelion.team1.mindscape.content.entity.Book; +import likelion.team1.mindscape.content.entity.Movie; +import likelion.team1.mindscape.content.entity.Music; +import likelion.team1.mindscape.content.repository.BookRepository; +import likelion.team1.mindscape.content.repository.MovieRepository; +import likelion.team1.mindscape.content.repository.MusicRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.*; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ResponseService { + private final RedisTemplate redisTemplate; + private final BookRepository bookRepository; + private final MovieRepository movieRepository; + private final MusicRepository musicRepository; + + /** + * UserID 로 test 테이블에서 해당 사용자의 testId 전부 가져오기 + * + * @param userId + * @return + */ + public List getTestIdByUserId(Long userId) { + //TODO: test테이블에서 userId로 testId 가져오기 + List testIdList = new ArrayList<>(); + + return testIdList; + } + + /** + * TestId로 BookDto 가져오기 + * + * @param testId + * @return + */ + public List getBookDtoByTestId(Long testId) { + List bookList = new ArrayList<>(); + List book = chkRedis("book", testId); + List> bookDetails = getDetailsFromRedis("book", book); + + if (book.isEmpty() || bookDetails.isEmpty()) { + for (Map map : getFromSql("book", testId)) { + bookList.add(toBookDto(map)); + } + } else { + for (Map map : bookDetails) { + bookList.add(toBookDto(map)); + } + } + return bookList; + } + + /** + * TestId로 MusicDto 가져오기 + * + * @param testId + * @return + */ + public List getMusicDtoByTestId(Long testId) { + List musicList = new ArrayList<>(); + List music = chkRedis("music", testId); + List> musicDetails = getDetailsFromRedis("music", music); + + if (music.isEmpty() || musicDetails.isEmpty()) { + for (Map map : getFromSql("music", testId)) { + musicList.add(toMusicDto(map)); + } + } else { + for (Map map : musicDetails) { + musicList.add(toMusicDto(map)); + } + } + return musicList; + } + + /** + * TestId로 MovieDto 가져오기 + * + * @param testId + * @return + */ + public List getMovieDtoByTestId(Long testId) { + List movieList = new ArrayList<>(); + List movie = chkRedis("movie", testId); + List> movieDetails = getDetailsFromRedis("movie", movie); + + if (movie.isEmpty() || movieDetails.isEmpty()) { + for (Map map : getFromSql("movie", testId)) { + movieList.add(toMovieDto(map)); + } + } else { + for (Map map : movieDetails) { + movieList.add(toMovieDto(map)); + } + } + return movieList; + } + + /** + * Test ID로 size만큼 저장 많이 되어 있는 것 가져오기 + * + * @param ids + * @param size + * @return + */ + public HistoryResponse getRankingResponse(List ids, int size) { + List books = getTopBooksByIds(ids, size); + List music = getTopMusicByIds(ids, size); + List movie = getTopMoviesByIds(ids, size); + + HistoryResponse.Recommend recommend = new HistoryResponse.Recommend(books, music, movie); + return new HistoryResponse(0L, recommend); + } + + /** + * Test ID로 sql에서 책 size만큼 가져오기 + * + * @param ids + * @param size + * @return + */ + private List getTopBooksByIds(List ids, int size) { + List bookIds = getContentCountByType("book", ids, size) + .stream().map(book -> book.getIds().get(0)).toList(); + + List books = new ArrayList<>(); + for (Long id : bookIds) { + Book tmp = bookRepository.findById(id).orElse(null); + if (tmp != null) { + books.add(new BookDto(tmp.getTitle(), tmp.getAuthor(), tmp.getDescription(), tmp.getImage())); + } + } + return books; + } + + /** + * Test ID로 sql에서 음악 size만큼 가져오기 + * + * @param ids + * @param size + * @return + */ + private List getTopMusicByIds(List ids, int size) { + List musicIds = getContentCountByType("music", ids, size) + .stream().map(music -> music.getIds().get(0)).toList(); + + List music = new ArrayList<>(); + for (Long id : musicIds) { + Music tmp = musicRepository.findById(id).orElse(null); + if (tmp != null) { + music.add(new MusicDto(tmp.getTitle(), tmp.getArtist(), tmp.getElbum())); + } + } + return music; + } + + /** + * Test ID로 sql에서 영화 size만큼 가져오기 + * + * @param ids + * @param size + * @return + */ + private List getTopMoviesByIds(List ids, int size) { + List movieIds = getContentCountByType("movie", ids, size) + .stream().map(movie -> movie.getIds().get(0)).toList(); + + List movie = new ArrayList<>(); + for (Long id : movieIds) { + Movie tmp = movieRepository.findById(id).orElse(null); + if (tmp != null) { + movie.add(new MovieDto(tmp.getTitle(), tmp.getReleaseDate(), tmp.getDescription(), tmp.getPoster())); + } + } + return movie; + } + + /** + * Test ID로 sql에서 컨텐츠의 개수를 개수가 많은 순으로 limit만큼 가져오기 + * + * @param contentType + * @param recomIds + * @param limit + * @return + */ + private List getContentCountByType(String contentType, List recomIds, int limit) { + switch (contentType.toLowerCase()) { + case "book": + List bookResults = bookRepository.getBookCountWithIds(recomIds, limit); + return bookResults.stream() + .map(row -> { + String title = (String) row[0]; + Long cnt = ((Number) row[1]).longValue(); + String recomIdsStr = (String) row[2]; + List recomIdsList = Arrays.stream(recomIdsStr.split(",")) + .map(Long::parseLong) + .collect(Collectors.toList()); + return new ContentCountDto(title, cnt, recomIdsList); + }) + .collect(Collectors.toList()); + + case "movie": + List movieResults = movieRepository.getMovieCountWithIds(recomIds, limit); + return movieResults.stream() + .map(row -> { + String title = (String) row[0]; + Long cnt = ((Number) row[1]).longValue(); + String recomIdsStr = (String) row[2]; + List recomIdsList = Arrays.stream(recomIdsStr.split(",")) + .map(Long::parseLong) + .collect(Collectors.toList()); + return new ContentCountDto(title, cnt, recomIdsList); + }) + .collect(Collectors.toList()); + + case "music": + List musicResults = musicRepository.getMusicCountWithIds(recomIds, limit); + return musicResults.stream() + .map(row -> { + String title = (String) row[0]; + Long cnt = ((Number) row[1]).longValue(); + String recomIdsStr = (String) row[2]; + List recomIdsList = Arrays.stream(recomIdsStr.split(",")) + .map(Long::parseLong) + .collect(Collectors.toList()); + return new ContentCountDto(title, cnt, recomIdsList); + }) + .collect(Collectors.toList()); + + default: + throw new IllegalArgumentException("Unknown content type: " + contentType); + } + } + + /** + * Redis에 저장되어 있는지 확인 + * + * @param type + * @param testId + * @return + */ + private List chkRedis(String type, Long testId) { + String pattern = "user:*:test:" + testId + ":" + type; + log.info("Redis checking for {}, testId: {}", type, testId); + Set keys = redisTemplate.keys(pattern); + + if (keys == null || keys.isEmpty()) { + return Collections.emptyList(); + } + + List results = new ArrayList<>(); + for (String key : keys) { + List list = redisTemplate.opsForList().range(key, 0, -1); + if (list != null) { + results.addAll(list); + } + } + return results; + } + + /** + * Title로 Redis에서 정보 가져오기 + * + * @param type + * @param list + * @return + */ + private List> getDetailsFromRedis(String type, List list) { + List> res = new ArrayList<>(); + for (Object o : list) { + if (res.size() == 3) return res; + String key = type + ":" + o; + log.info("Redis in use for {}", key); + res.add(redisTemplate.opsForHash().entries(key)); + } + return res; + } + + /** + * Sql에 저장되어 있는 정보 가져오기 + * + * @param type + * @param testId + * @return + */ + private List> getFromSql(String type, Long testId) { + Long recomId = testId; + + List> res = new ArrayList<>(); + + if (type == "book") { + log.info("SQL in use for {}, recomId: {}", type, recomId); + List books = bookRepository.findTop3AllByRecommendedContent_RecomId(recomId); + for (Book book : books) { + Map tmp = new HashMap<>(); + tmp.put("title", book.getTitle()); + tmp.put("author", book.getAuthor()); + tmp.put("description", book.getDescription()); + tmp.put("image", book.getImage()); + res.add(tmp); + } + return res; + } + + if (type == "music") { + log.info("SQL in use for {}, recomId: {}", type, recomId); + List music = musicRepository.findTop3AllByRecommendedContent_RecomId(recomId); + for (Music m : music) { + Map tmp = new HashMap<>(); + tmp.put("title", m.getTitle()); + tmp.put("artist", m.getArtist()); + tmp.put("album", m.getElbum()); + res.add(tmp); + } + return res; + } + + if (type == "movie") { + log.info("SQL in use for {}, recomId: {}", type, recomId); + List movies = movieRepository.findTop3AllByRecommendedContent_RecomId(recomId); + for (Movie movie : movies) { + Map tmp = new HashMap<>(); + tmp.put("title", movie.getTitle()); + tmp.put("release_date", movie.getReleaseDate()); + tmp.put("description", movie.getDescription()); + tmp.put("poster", movie.getPoster()); + res.add(tmp); + } + return res; + } + return null; + } + + /** + * 가져온 정보를 BookDto로 변환 + * + * @param bookMap + * @return + */ + private BookDto toBookDto(Map bookMap) { + BookDto res = new BookDto( + (String) bookMap.get("title"), + (String) bookMap.get("author"), + (String) bookMap.get("description"), + (String) bookMap.get("image") + ); + return res; + } + + /** + * 가져온 정보를 MusicDto로 변환 + * + * @param musicMap + * @return + */ + private MusicDto toMusicDto(Map musicMap) { + MusicDto res = new MusicDto( + (String) musicMap.get("title"), + (String) musicMap.get("artist"), + (String) musicMap.get("album") + ); + return res; + } + + /** + * 가져온 정보를 MovieDto로 변환 + * + * @param movieMap + * @return + */ + private MovieDto toMovieDto(Map movieMap) { + String title = (String) movieMap.get("title"); + Object releaseDateObj = movieMap.get("release_date"); + String description = (String) movieMap.get("description"); + String poster = (String) movieMap.get("poster"); + + Date releaseDate = null; + if (releaseDateObj instanceof String) { + try { + releaseDate = new SimpleDateFormat("yyyy-MM-dd").parse((String) releaseDateObj); + } catch (ParseException e) { + log.warn("Invalid date format: {}", releaseDateObj); + } + } else if (releaseDateObj instanceof java.sql.Timestamp) { + releaseDate = new Date(((java.sql.Timestamp) releaseDateObj).getTime()); + } else if (releaseDateObj instanceof Date) { + releaseDate = (Date) releaseDateObj; + } + + return new MovieDto(title, releaseDate, description, poster); + } +} diff --git a/mindscape-service/src/main/resources/application.properties b/mindscape-service/src/main/resources/application.properties deleted file mode 100644 index 5e865eb..0000000 --- a/mindscape-service/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=mindscape-service diff --git a/terraform/.DS_Store b/terraform/.DS_Store new file mode 100644 index 0000000..0083075 Binary files /dev/null and b/terraform/.DS_Store differ diff --git a/terraform/.terraform.lock.hcl b/terraform/.terraform.lock.hcl new file mode 100644 index 0000000..983563c --- /dev/null +++ b/terraform/.terraform.lock.hcl @@ -0,0 +1,104 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/gavinbunney/kubectl" { + version = "1.14.0" + constraints = "~> 1.14.0" + hashes = [ + "h1:Ck8Re/28x7VBI5ArFg0VSg1woPu/APm1ZbMuzqUdnPo=", + "zh:0350f3122ff711984bbc36f6093c1fe19043173fad5a904bce27f86afe3cc858", + "zh:07ca36c7aa7533e8325b38232c77c04d6ef1081cb0bac9d56e8ccd51f12f2030", + "zh:0c351afd91d9e994a71fe64bbd1662d0024006b3493bb61d46c23ea3e42a7cf5", + "zh:39f1a0aa1d589a7e815b62b5aa11041040903b061672c4cfc7de38622866cbc4", + "zh:428d3a321043b78e23c91a8d641f2d08d6b97f74c195c654f04d2c455e017de5", + "zh:4baf5b1de2dfe9968cc0f57fd4be5a741deb5b34ee0989519267697af5f3eee5", + "zh:6131a927f9dffa014ab5ca5364ac965fe9b19830d2bbf916a5b2865b956fdfcf", + "zh:c62e0c9fd052cbf68c5c2612af4f6408c61c7e37b615dc347918d2442dd05e93", + "zh:f0beffd7ce78f49ead612e4b1aefb7cb6a461d040428f514f4f9cc4e5698ac65", + ] +} + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.7.0" + constraints = ">= 5.0.0" + hashes = [ + "h1:FmriT5DaLjFWBHd8xlo3OAHtWemO59NNIawdVt76VZ8=", + "zh:3c0a256f813e5e2c1e1aa137204ad9168ebe487f6cee874af9e9c78eb300568e", + "zh:3c49dd75ea28395b29ba259988826b956c8adf6c0b59dd8874feb4f47bad976a", + "zh:3e6e3e3bfc6594f4f9e2c017ee588c5fcad394b87dd0b68a3f37cd66001f3c8c", + "zh:3f9b55826eeebf9b2ed448fc111d772c703e1edc6678e1bb646e66f3c3f9308f", + "zh:44e4ced936045ddc42d22c653a6427e7eb2b7aee918dff8438da0cb40996beb4", + "zh:474ab4d63918f41e8ea1cef43aeb1c719629dbf289db175c95de1431a8853ae7", + "zh:71b9e1d82c5ccc8d9bf72b3712c2b90722fc1f35a0f0f7a9557b9ee01971e6e2", + "zh:7723256d6ccc55f4000d1df8db202b02b30a7d917f5d31624c717e14ba15ea95", + "zh:82174836faa830aff0e47ea61d4cfbb5c97e1e944b1978f1d933acd37f584c88", + "zh:8e62fdc10206ba7232eec991e5a387378f2fbe47cc717b7f60eeb1df2c974514", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:be24dd2d53b224d7098e75ca432746e3420ce071189eea100aa8cbcd2498d389", + "zh:d27651d0e458933127ddca35a833e1a0f0ff0c131391288b3239763a2fd8f96f", + "zh:d33c181fff1b96bf8366e6c3d92408370b21649291e8f4d1f7e9a3fbb920fc9d", + "zh:edc0a0a84f85036c6d3df29d09557bd43206d9ee57b10542b484050f0f34d242", + ] +} + +provider "registry.terraform.io/hashicorp/helm" { + version = "3.0.2" + constraints = ">= 2.12.0" + hashes = [ + "h1:KO2WWUKRSnAXKM8b1lxZlAuzXpnOkIgptuD/Shdz1Oc=", + "h1:tOye2RnjFNXH236AsqGaIWtz4j6PZrpPuJhOSBt0KxU=", + "zh:2778de76c7dfb2e85c75fe6de3c11172a25551ed499bfb9e9f940a5be81167b0", + "zh:3b4c436a41e4fbae5f152852a9bd5c97db4460af384e26977477a40adf036690", + "zh:617a372f5bb2288f3faf5fd4c878a68bf08541cf418a3dbb8a19bc41ad4a0bf2", + "zh:84de431479548c96cb61c495278e320f361e80ab4f8835a5425ece24a9b6d310", + "zh:8b4cf5f81d10214e5e1857d96cff60a382a22b9caded7f5d7a92e5537fc166c1", + "zh:baeb26a00ffbcf3d507cdd940b2a2887eee723af5d3319a53eec69048d5e341e", + "zh:ca05a8814e9bf5fbffcd642df3a8d9fae9549776c7057ceae6d6f56471bae80f", + "zh:ca4bf3f94dedb5c5b1a73568f2dad7daf0ef3f85e688bc8bc2d0e915ec148366", + "zh:d331f2129fd3165c4bda875c84a65555b22eb007801522b9e017d065ac69b67e", + "zh:e583b2b478dde67da28e605ab4ef6521c2e390299b471d7d8ef05a0b608dcdad", + "zh:f238b86611647c108c073d265f8891a2738d3158c247468ae0ff5b1a3ac4122a", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} + +provider "registry.terraform.io/hashicorp/kubernetes" { + version = "2.38.0" + constraints = ">= 2.20.0" + hashes = [ + "h1:1OF0rDUteVdoX04BrtmTc0T4NOo6+D0utmK6hM0EzZw=", + "h1:soK8Lt0SZ6dB+HsypFRDzuX/npqlMU6M0fvyaR1yW0k=", + "zh:0af928d776eb269b192dc0ea0f8a3f0f5ec117224cd644bdacdc682300f84ba0", + "zh:1be998e67206f7cfc4ffe77c01a09ac91ce725de0abaec9030b22c0a832af44f", + "zh:326803fe5946023687d603f6f1bab24de7af3d426b01d20e51d4e6fbe4e7ec1b", + "zh:4a99ec8d91193af961de1abb1f824be73df07489301d62e6141a656b3ebfff12", + "zh:5136e51765d6a0b9e4dbcc3b38821e9736bd2136cf15e9aac11668f22db117d2", + "zh:63fab47349852d7802fb032e4f2b6a101ee1ce34b62557a9ad0f0f0f5b6ecfdc", + "zh:924fb0257e2d03e03e2bfe9c7b99aa73c195b1f19412ca09960001bee3c50d15", + "zh:b63a0be5e233f8f6727c56bed3b61eb9456ca7a8bb29539fba0837f1badf1396", + "zh:d39861aa21077f1bc899bc53e7233262e530ba8a3a2d737449b100daeb303e4d", + "zh:de0805e10ebe4c83ce3b728a67f6b0f9d18be32b25146aa89116634df5145ad4", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:faf23e45f0090eef8ba28a8aac7ec5d4fdf11a36c40a8d286304567d71c1e7db", + ] +} + +provider "registry.terraform.io/hashicorp/tls" { + version = "4.1.0" + hashes = [ + "h1:y9cHrgcuaZt592In6xQzz1lx7k/B9EeWrAb8K7QqOgU=", + "h1:zEv9tY1KR5vaLSyp2lkrucNJ+Vq3c+sTFK9GyQGLtFs=", + "zh:14c35d89307988c835a7f8e26f1b83ce771e5f9b41e407f86a644c0152089ac2", + "zh:2fb9fe7a8b5afdbd3e903acb6776ef1be3f2e587fb236a8c60f11a9fa165faa8", + "zh:35808142ef850c0c60dd93dc06b95c747720ed2c40c89031781165f0c2baa2fc", + "zh:35b5dc95bc75f0b3b9c5ce54d4d7600c1ebc96fbb8dfca174536e8bf103c8cdc", + "zh:38aa27c6a6c98f1712aa5cc30011884dc4b128b4073a4a27883374bfa3ec9fac", + "zh:51fb247e3a2e88f0047cb97bb9df7c228254a3b3021c5534e4563b4007e6f882", + "zh:62b981ce491e38d892ba6364d1d0cdaadcee37cc218590e07b310b1dfa34be2d", + "zh:bc8e47efc611924a79f947ce072a9ad698f311d4a60d0b4dfff6758c912b7298", + "zh:c149508bd131765d1bc085c75a870abb314ff5a6d7f5ac1035a8892d686b6297", + "zh:d38d40783503d278b63858978d40e07ac48123a2925e1a6b47e62179c046f87a", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + "zh:fb07f708e3316615f6d218cec198504984c0ce7000b9f1eebff7516e384f4b54", + ] +} diff --git a/terraform/aws-auth.tf b/terraform/aws-auth.tf new file mode 100644 index 0000000..e69de29 diff --git a/terraform/main.tf b/terraform/main.tf new file mode 100644 index 0000000..852cb9e --- /dev/null +++ b/terraform/main.tf @@ -0,0 +1,406 @@ + +# eks +module "eks" { + source = "./modules/eks" + team_name = var.team_name + subnet_ids = module.subnet.private_subnet_ids + cluster_iam_role_arn = module.iam.eks_cluster_role_arn + node_iam_role_arn = module.iam.eks_node_role_arn +} + +# ec2 bastion +module "bastion" { + source = "./modules/bastion" + team_name = var.team_name + ami_id = var.ami_id + instance_type = "t3.medium" + public_subnet_id = module.subnet.public_subnet_ids[0] + security_group_id = module.sg.bastion_sg_id + iam_instance_profile_name = module.iam.bastion_instance_profile_name + cluster_name = module.eks.cluster_name + eks_node_role_arn = module.iam.eks_node_role_arn + bastion_role_arn = module.iam.bastion_role_arn + key_name = var.bastion_key_name + bastion_role_name = module.iam.bastion_role_name + + depends_on = [ + module.eks, # 클러스터 먼저 생성 + module.iam # IAM 역할도 먼저 있어야 함 + ] +} + +#iam +module "iam" { + source = "./modules/iam" + team_name = var.team_name + cluster_name = module.eks.cluster_name + oidc_url = module.eks.oidc_url +} + + +module "alb_irsa" { + source = "./modules/irsa/alb" + team_name = var.team_name + oidc_url = module.eks.oidc_url + cluster_name = module.eks.cluster_name + namespace = "argocd" + service_account_name = "aws-load-balancer-controller" + + depends_on = [module.eks] +} + + +# module "ebs_csi_irsa" { +# source = "./modules/irsa/ebs_csi" +# team_name = var.team_name +# oidc_url = module.eks.oidc_url +# cluster_name = module.eks.cluster_name +# namespace = "kube-system" +# service_account_name = "ebs-csi-controller-sa" + +# depends_on = [module.eks] +# } + +# module "ebs_csi_driver" { +# source = "./modules/ebs-csi-driver" +# namespace = "kube-system" +# chart_version = "2.30.0" +# service_account_name = "ebs-csi-controller-sa" +# irsa_role_arn = module.ebs_csi_irsa.ebs_csi_irsa_role_arn + +# providers = { +# helm.eks = helm.eks +# } + +# depends_on = [ +# module.ebs_csi_irsa, +# module.eks +# ] +# } + +#security group +module "sg" { + source = "./modules/security-group" + vpc_id = module.vpc.vpc_id + team_name = var.team_name + cluster_name = module.eks.cluster_name +} + +#vpc +module "vpc" { + source = "./modules/network/vpc" + name_prefix = var.team_name + environment = "dev" + vpc_cidr = "192.168.0.0/16" +} + +#subnet +module "subnet" { + source = "./modules/network/subnet" + name_prefix = var.team_name + environment = "dev" + vpc_id = module.vpc.vpc_id + public_subnet_cidrs = ["192.168.1.0/24", "192.168.2.0/24"] + private_subnet_cidrs = ["192.168.3.0/24", "192.168.4.0/24"] + azs = ["ap-northeast-2a", "ap-northeast-2c"] + +} + + +# route table +module "route_table" { + source = "./modules/network/route-table" + name_prefix = var.team_name + environment = "dev" + vpc_id = module.vpc.vpc_id + igw_id = module.internet_gateway.igw_id + public_subnet_ids = module.subnet.public_subnet_ids +} + +# nat gateway +module "nat_gateway" { + source = "./modules/network/nat-gateway" + name_prefix = var.team_name + environment = "dev" + vpc_id = module.vpc.vpc_id + igw_id = module.internet_gateway.igw_id + public_subnet_id = module.subnet.public_subnet_ids[0] + private_subnet_ids = module.subnet.private_subnet_ids +} + + +# internet gateway +module "internet_gateway" { + source = "./modules/network/internet-gateway" + name_prefix = var.team_name + environment = "dev" + vpc_id = module.vpc.vpc_id +} + + + + +#ebs 스토리지 클래스 +# module "ebs_storage_class" { +# source = "./modules/storageclass" + +# name = "ebs-sc" +# volume_type = "gp3" +# fs_type = "ext4" +# reclaim_policy = "Delete" +# binding_mode = "WaitForFirstConsumer" + +# providers = { +# kubernetes.eks = kubernetes.eks +# } + +# depends_on = [ +# module.ebs_csi_irsa, +# module.eks, +# module.ebs_csi_driver +# ] +# } + + +module "app_namespace" { + source = "./modules/namespace" + name = "app" + labels = { + "managed-by" = "terraform" + } + + providers = { + kubernetes.eks = kubernetes.eks + } + + depends_on = [ + module.eks, + module.bastion + ] +} + + +# argocd 모듈 및 네임스페이스 + +module "argocd_namespace" { + source = "./modules/namespace" + name = "argocd" + labels = { + "managed-by" = "terraform" + } + + providers = { + kubernetes.eks = kubernetes.eks + } + + depends_on = [ + module.eks, + module.bastion + ] +} + +module "argocd" { + source = "./modules/argocd" + #삭제 필요~ + + #enabled = false + #enabled = true + + + namespace = module.argocd_namespace.name + chart_version = "5.51.6" + providers = { + helm = helm.eks + kubernetes = kubernetes.eks + } + + depends_on = [ + module.eks, + module.bastion, + module.argocd_namespace + ] +} + +# 프로메테오스 모듈 및 네임스페이스 + +module "prometheus_namespace" { + source = "./modules/namespace" + name = "prometheus" + labels = { + "managed-by" = "terraform" + } + + providers = { + kubernetes.eks = kubernetes.eks + } + depends_on = [ + module.eks, + module.bastion, + # module.ebs_csi_driver, + # module.ebs_storage_class + + ] +} + +module "prometheus" { + source = "./modules/monitoring/prometheus" + #삭제 필요~ + + #enabled = false + #enabled = true + + namespace = module.prometheus_namespace.name + chart_version = "75.15.1" + + providers = { + helm = helm.eks + kubernetes = kubernetes.eks + } + + depends_on = [ + module.eks, + module.bastion, + + + module.prometheus_namespace, + # module.ebs_csi_driver, + # module.ebs_storage_class + + ] + +} + +# 그라파나 모듈 및 네임스페이스 +module "grafana_namespace" { + source = "./modules/namespace" + name = "grafana" + labels = { + "managed-by" = "terraform" + } + + providers = { + kubernetes.eks = kubernetes.eks + } + depends_on = [ + module.eks, + module.bastion + ] +} + +module "grafana" { + source = "./modules/monitoring/grafana" + #삭제 필요~ + #enabled = false + #enabled = true + + namespace = module.grafana_namespace.name + chart_version = "7.3.11" + + providers = { + helm = helm.eks + kubernetes = kubernetes.eks + } + + depends_on = [ + module.eks, + module.bastion, + module.grafana_namespace + ] + +} + + +# Elasticache +module "elasticache" { + source = "./modules/elasticache" + vpc_id = module.vpc.vpc_id + name = "team1-redis" + subnet_ids = module.subnet.private_subnet_ids + security_group_ids = [module.elasticache.elasticache_sg_id] + node_type = "cache.m4.large" + num_nodes = 1 + port = 6379 + engine_version = "7.0" + parameter_group_name = "default.redis7" +} + +# RDS +module "rds" { + source = "./modules/rds" + vpc_id = module.vpc.vpc_id + db_subnet_ids = module.subnet.private_subnet_ids + db_name = "mindscape" + db_username = "root" + db_password = var.db_password +} + +# ECR +module "ecr" { + source = "./modules/ecr" +} + +terraform { + backend "remote" { + organization = "final-team1" + + + workspaces { + name = "Final-Team1-Infra" + } + } +} + +#karpenter - aws_ami 정의(ububntu용) +data "aws_ami" "ubuntu" { + most_recent = true + + filter { + name = "name" + values = ["ubuntu/images/hvm-ssd/ubuntu-*-20.04-amd64-server-*"] + } + + filter { + name = "virtualization-type" + values = ["hvm"] + } + + owners = ["099720109477"] # Canonical +} + + +# karpenter +module "karpenter" { + source = "./modules/karpenter" + + cluster_name = module.eks.cluster_name + cluster_endpoint = module.eks.cluster_endpoint + karpenter_version = "1.5.0" + namespace = "kube-system" + irsa_role_arn = module.irsa_karpenter_controller.karpenter_controller_role_arn + # oidc_provider_url = module.eks.oidc_url + + subnet_ids = module.subnet.private_subnet_ids + cluster_security_group_id = module.eks.cluster_security_group_id + instance_profile = module.iam.karpenter_instance_profile_name + ubuntu_ami_id = data.aws_ami.ubuntu.id + bastion_host = module.bastion.bastion_public_ip + bastion_user = "ubuntu" + + providers = { + kubernetes = kubernetes.eks # 루트에 선언된 plain provider 이름을 그대로 넘깁니다 + helm = helm.eks + kubectl = kubectl.eks + + } + depends_on = [module.eks, module.irsa_karpenter_controller, module.bastion] +} + + + +module "irsa_karpenter_controller" { + source = "./modules/irsa/karpenter-controller" + cluster_name = module.eks.cluster_name + oidc_provider_arn = module.eks.oidc_provider_arn + oidc_provider_url = module.eks.oidc_url + team_name = var.team_name +} diff --git a/terraform/modules/.DS_Store b/terraform/modules/.DS_Store new file mode 100644 index 0000000..fb5177b Binary files /dev/null and b/terraform/modules/.DS_Store differ diff --git a/terraform/modules/argocd/main.tf b/terraform/modules/argocd/main.tf new file mode 100644 index 0000000..8397adf --- /dev/null +++ b/terraform/modules/argocd/main.tf @@ -0,0 +1,45 @@ +# resource "kubernetes_namespace" "argocd" { +# metadata { +# name = var.namespace +# } +# } + + +resource "helm_release" "argocd" { + #삭제 필요~ + #count = var.enabled ? 1 : 0 + name = "argocd" + namespace = var.namespace + repository = "https://argoproj.github.io/argo-helm" + chart = "argo-cd" + version = var.chart_version + create_namespace = false + + values = [file("${path.module}/values.yaml")] + +} + + +resource "kubernetes_cluster_role_binding" "argocd_controller_admin" { + #삭제 필요~ + #count = var.enabled ? 1 : 0 + provider = kubernetes + + metadata { + name = "argocd-application-controller-cluster-admin" + } + + role_ref { + api_group = "rbac.authorization.k8s.io" + kind = "ClusterRole" + name = "cluster-admin" + } + + subject { + kind = "ServiceAccount" + name = "argocd-application-controller" + namespace = var.namespace + } + + depends_on = [helm_release.argocd] +} diff --git a/terraform/modules/argocd/outputs.tf b/terraform/modules/argocd/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/terraform/modules/argocd/values.yaml b/terraform/modules/argocd/values.yaml new file mode 100644 index 0000000..0ff2503 --- /dev/null +++ b/terraform/modules/argocd/values.yaml @@ -0,0 +1,14 @@ +configs: + params: + server.insecure: true + cm: + application.instanceLabelKey: argocd.argoproj.io/instance + +server: + service: + type: LoadBalancer + annotations: + service.beta.kubernetes.io/aws-load-balancer-type: "nlb" + service.beta.kubernetes.io/aws-load-balancer-scheme: "internet-facing" # 내부 전용이면 아래 라인 사용 + # service.beta.kubernetes.io/aws-load-balancer-internal: "true" + service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true" diff --git a/terraform/modules/argocd/variables.tf b/terraform/modules/argocd/variables.tf new file mode 100644 index 0000000..9aee524 --- /dev/null +++ b/terraform/modules/argocd/variables.tf @@ -0,0 +1,16 @@ +variable "namespace" { + type = string + default = "argocd" +} + +variable "chart_version" { + type = string + default = "5.51.6" +} + +#삭제 필요~ +# variable "enabled" { +# description = "Enable or disable ArgoCD module" +# type = bool +# default = true +# } diff --git a/terraform/modules/argocd/versions.tf b/terraform/modules/argocd/versions.tf new file mode 100644 index 0000000..a1c7f3e --- /dev/null +++ b/terraform/modules/argocd/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + helm = { + source = "hashicorp/helm" + version = ">= 2.12.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.20.0" + } + } + required_version = ">= 1.3.0" +} \ No newline at end of file diff --git a/terraform/modules/bastion/main.tf b/terraform/modules/bastion/main.tf new file mode 100644 index 0000000..112e3ab --- /dev/null +++ b/terraform/modules/bastion/main.tf @@ -0,0 +1,20 @@ +resource "aws_instance" "bastion" { + ami = var.ami_id + instance_type = var.instance_type + subnet_id = var.public_subnet_id + vpc_security_group_ids = [var.security_group_id] + associate_public_ip_address = true + + iam_instance_profile = var.iam_instance_profile_name + key_name = var.key_name + + user_data = templatefile("${path.module}/scripts/bastion-setup.sh", { + cluster_name = var.cluster_name + eks_node_role_arn = var.eks_node_role_arn + bastion_role_arn = var.bastion_role_arn + bastion_role_name = var.bastion_role_name + }) + tags = { + Name = "${var.team_name}-bastion" + } +} diff --git a/terraform/modules/bastion/outputs.tf b/terraform/modules/bastion/outputs.tf new file mode 100644 index 0000000..cadceb9 --- /dev/null +++ b/terraform/modules/bastion/outputs.tf @@ -0,0 +1,16 @@ + +output "bastion_instance_id" { + description = "ID of the Bastion EC2 instance" + value = aws_instance.bastion.id +} + +output "bastion_public_ip" { + description = "Public IP address of the Bastion EC2" + value = aws_instance.bastion.public_ip +} + +output "bastion_private_ip" { + description = "Private IP address of the Bastion EC2" + value = aws_instance.bastion.private_ip +} + diff --git a/terraform/modules/bastion/scripts/bastion-setup.sh b/terraform/modules/bastion/scripts/bastion-setup.sh new file mode 100644 index 0000000..992aab6 --- /dev/null +++ b/terraform/modules/bastion/scripts/bastion-setup.sh @@ -0,0 +1,234 @@ +#!/bin/bash +hostnamectl --static set-hostname "${cluster_name}-bastion-EC2" + +# 계정 설정 +echo 'root:eks123' | chpasswd +sed -i "s/^#PermitRootLogin prohibit-password/PermitRootLogin yes/g" /etc/ssh/sshd_config +sed -i "s/^PasswordAuthentication no/PasswordAuthentication yes/g" /etc/ssh/sshd_config +rm -rf /root/.ssh/authorized_keys +systemctl restart ssh + +# 편의 설정 +echo 'alias vi=vim' >> /etc/profile +echo "sudo su -" >> /home/ubuntu/.bashrc +timedatectl set-timezone Asia/Seoul + +# 필수 패키지 +apt update -y +apt install -y tree jq git htop unzip vim docker.io mysql-client redis-tools + +# aws cli +curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" +unzip awscliv2.zip +./aws/install +echo 'export AWS_PAGER=""' >> /etc/profile + +# kubectl 설치 (v1.29.7) +curl -LO "https://dl.k8s.io/release/v1.29.7/bin/linux/amd64/kubectl" +chmod +x kubectl +mv kubectl /usr/local/bin/kubectl + +# EKS 준비 대기 +for i in {1..30}; do + aws eks describe-cluster --region ap-northeast-2 --name "${cluster_name}" >/dev/null 2>&1 && break + echo "[INFO] Waiting for EKS API to become available... ($i/30)" + sleep 10 +done + + +# 3. kubeconfig 설정 +aws eks --region ap-northeast-2 update-kubeconfig --name "${cluster_name}" + + +cat < /root/aws-auth.yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: aws-auth + namespace: kube-system +data: + mapRoles: | + # 기존 EKS 관리 노드 그룹 Role + - rolearn: arn:aws:iam::194722398200:role/Team1-backend-eks-node-role + username: system:node:{{EC2PrivateDNSName}} + groups: + - system:bootstrappers + - system:nodes + + # Bastion EC2 용 Role (관리자) + - rolearn: arn:aws:iam::194722398200:role/${bastion_role_name} + username: bastion-admin + groups: + - system:masters + + # ▶ Karpenter 노드용 Role (추가) + - rolearn: arn:aws:iam::194722398200:role/KarpenterNodeRole-Team1-backend-eks-cluster + username: system:node:{{EC2PrivateDNSName}} + groups: + - system:bootstrappers + - system:nodes + + mapUsers: | + # IAM 유저 매핑 (필요시) + - userarn: arn:aws:iam::194722398200:user/lion3fteam01 + username: lion3fteam01 + groups: + - karpenter-iac-group + - system:bootstrappers + - system:nodes + +EOF + +# 3. aws-auth.yaml 적용 재시도 +for i in {1..10}; do + kubectl apply -f /root/aws-auth.yaml && break + echo "[WARN] aws-auth apply failed, retrying ($i/10)..." + sleep 5 +done + +# 4. RBAC 반영 대기 및 검증 +for i in {1..10}; do + kubectl get nodes && break + echo "[INFO] Waiting for RBAC to take effect ($i/10)..." + sleep 5 +done + +# helm 설치 (v3.14.0 고정) +curl -LO https://get.helm.sh/helm-v3.14.0-linux-amd64.tar.gz +tar -zxvf helm-v3.14.0-linux-amd64.tar.gz +mv linux-amd64/helm /usr/local/bin/helm +chmod +x /usr/local/bin/helm +rm -rf linux-amd64 helm-v3.14.0-linux-amd64.tar.gz + +# eksctl +curl --silent --location "https://github.com/weaveworks/eksctl/releases/latest/download/eksctl_$(uname -s)_amd64.tar.gz" | tar xz -C /tmp +mv /tmp/eksctl /usr/local/bin + +# docker 권한 +usermod -aG docker ubuntu +newgrp docker + +# SSH 키 생성 +ssh-keygen -t rsa -N "" -f /root/.ssh/id_rsa + +echo "cloud-init complete." + +# k6 설치 ----- +# k6 설치 블록 +echo "[INFO] Installing k6..." + +set -euo pipefail + +# 필수 패키지 설치 +echo "[INFO] Installing dependencies for k6..." +apt update -y +apt install -y gnupg curl ca-certificates || { echo "[ERROR] Failed to install dependencies"; exit 1; } + +# GPG 키 등록 +echo "[INFO] Adding GPG key for k6..." +curl -fsSL https://dl.k6.io/key.gpg | gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg || { + echo "[ERROR] Failed to fetch GPG key"; exit 1; +} + +# APT 저장소 등록 +echo "[INFO] Adding APT repo for k6..." +echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \ + > /etc/apt/sources.list.d/k6.list + +# k6 설치 +echo "[INFO] Updating package list and installing k6..." +apt update -y +apt install -y k6 || { echo "[ERROR] Failed to install k6"; exit 1; } + +# 설치 확인 +echo "[INFO] k6 version:" +k6 version || { echo "[ERROR] k6 not found after install"; exit 1; } + + + +# kube-ops-view 설치 --- +# kube-ops-view 설치 +echo "[INFO] Installing kube-ops-view..." + +# Helm 설치 여부 확인 +if ! command -v helm &>/dev/null; then + echo "[ERROR] Helm is not installed. Skipping kube-ops-view install." + exit 1 +fi + +# Helm Repo 등록 +helm repo add geek-cookbook https://geek-cookbook.github.io/charts/ || { + echo "[ERROR] Failed to add helm repo"; exit 1; +} + +helm repo update || { + echo "[ERROR] Failed to update helm repo"; exit 1; +} + +# 설치 실행 +helm install kube-ops-view geek-cookbook/kube-ops-view \ + --version 1.2.2 \ + --set env.TZ="Asia/Seoul" \ + --namespace kube-system || { + echo "[ERROR] Failed to install kube-ops-view"; exit 1; + } + +echo "[INFO] kube-ops-view installation complete." + +# k6 설치 +echo "[INFO] Installing k6..." + +apt install -y gnupg curl ca-certificates + +curl -fsSL https://dl.k6.io/key.gpg | gpg --dearmor -o /usr/share/keyrings/k6-archive-keyring.gpg + +echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" \ + > /etc/apt/sources.list.d/k6.list + +apt update +apt install -y k6 + + +# kube-ops-view 설치 +echo "[INFO] Installing kube-ops-view..." + +helm repo add geek-cookbook https://geek-cookbook.github.io/charts/ +helm repo update + +helm install kube-ops-view geek-cookbook/kube-ops-view \ + --version 1.2.2 \ + --set env.TZ="Asia/Seoul" \ + --namespace kube-system + + + + # ─── Docker Compose 설치 및 InfluxDB 컨테이너 기동 ─── +echo "[INFO] Installing Docker Compose and launching InfluxDB container..." + +# Docker Compose 설치 +curl -SL https://github.com/docker/compose/releases/download/v2.39.1/docker-compose-linux-x86_64 \ + -o /usr/local/bin/docker-compose +chmod +x /usr/local/bin/docker-compose + +# docker-compose 파일 생성 +cat << 'EOF' > /home/ubuntu/docker-compose.yaml +version: '3.8' + +services: + influxdb: + image: influxdb:1.8 + container_name: influxdb + ports: + - "8086:8086" + volumes: + - influxdb_volume_data:/var/lib/influxdb + +volumes: + influxdb_volume_data: +EOF + +# 컨테이너 띄우기 +cd /home/ubuntu +docker-compose -f docker-compose.yaml up -d +echo "[INFO] InfluxDB is up on port 8086." + diff --git a/terraform/modules/bastion/variables.tf b/terraform/modules/bastion/variables.tf new file mode 100644 index 0000000..8320326 --- /dev/null +++ b/terraform/modules/bastion/variables.tf @@ -0,0 +1,52 @@ +variable "team_name" { + description = "Prefix for resource naming" + type = string +} + +variable "ami_id" { + description = "AMI ID to use for the bastion EC2 instance" + type = string +} + +variable "instance_type" { + description = "Instance type for the bastion EC2" + type = string + default = "t3.micro" +} + +variable "public_subnet_id" { + description = "ID of the public subnet to launch the bastion" + type = string +} + +variable "security_group_id" { + description = "Security group ID for bastion EC2" + type = string +} + +variable "iam_instance_profile_name" { + description = "IAM instance profile to attach to bastion EC2" + type = string +} + +variable "cluster_name" { + description = "EKS Cluster name for hostname or tagging" + type = string +} + +variable "key_name" { + description = "Name of the EC2 key pair to assign to the Bastion instance" + type = string +} + +variable "eks_node_role_arn" { + type = string +} + +variable "bastion_role_arn" { + type = string +} + +variable "bastion_role_name" { + type = string +} \ No newline at end of file diff --git a/terraform/modules/ebs-csi-driver/main.tf b/terraform/modules/ebs-csi-driver/main.tf new file mode 100644 index 0000000..29191e3 --- /dev/null +++ b/terraform/modules/ebs-csi-driver/main.tf @@ -0,0 +1,24 @@ +resource "helm_release" "ebs_csi_driver" { + provider = helm.eks + name = "aws-ebs-csi-driver" + namespace = var.namespace + repository = "https://kubernetes-sigs.github.io/aws-ebs-csi-driver" + chart = "aws-ebs-csi-driver" + version = var.chart_version + create_namespace = false + + values = [ + yamlencode({ + controller = { + serviceAccount = { + create = true + name = var.service_account_name + annotations = { + "eks.amazonaws.com/role-arn" = var.irsa_role_arn + } + } + } + }) + ] + wait = false +} \ No newline at end of file diff --git a/terraform/modules/ebs-csi-driver/outputs.tf b/terraform/modules/ebs-csi-driver/outputs.tf new file mode 100644 index 0000000..d8900a0 --- /dev/null +++ b/terraform/modules/ebs-csi-driver/outputs.tf @@ -0,0 +1,3 @@ +output "release_name" { + value = helm_release.ebs_csi_driver.name +} \ No newline at end of file diff --git a/terraform/modules/ebs-csi-driver/variables.tf b/terraform/modules/ebs-csi-driver/variables.tf new file mode 100644 index 0000000..f162397 --- /dev/null +++ b/terraform/modules/ebs-csi-driver/variables.tf @@ -0,0 +1,21 @@ +variable "namespace" { + description = "Namespace to install EBS CSI Driver" + type = string +} + +variable "chart_version" { + description = "Helm chart version for EBS CSI Driver" + type = string + default = "2.30.0" +} + +variable "service_account_name" { + description = "ServiceAccount name for EBS CSI Driver" + type = string + default = "ebs-csi-controller-sa" +} + +variable "irsa_role_arn" { + description = "IRSA Role ARN for EBS CSI Driver" + type = string +} \ No newline at end of file diff --git a/terraform/modules/ebs-csi-driver/version.tf b/terraform/modules/ebs-csi-driver/version.tf new file mode 100644 index 0000000..dbfc743 --- /dev/null +++ b/terraform/modules/ebs-csi-driver/version.tf @@ -0,0 +1,14 @@ +terraform { + required_providers { + helm = { + source = "hashicorp/helm" + version = ">= 2.12.0" + configuration_aliases = [helm.eks] # ← alias 등록! + } + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.20.0" + } + } + required_version = ">= 1.3.0" +} diff --git a/terraform/modules/ecr/main.tf b/terraform/modules/ecr/main.tf new file mode 100644 index 0000000..ad919cc --- /dev/null +++ b/terraform/modules/ecr/main.tf @@ -0,0 +1,13 @@ +resource "aws_ecr_repository" "msa_services" { + for_each = toset(var.services) + name = "${var.team_name}-${each.key}" + image_tag_mutability = "MUTABLE" + + image_scanning_configuration { + scan_on_push = true + } + + tags = { + Name = "${var.team_name}-${each.key}" + } +} \ No newline at end of file diff --git a/terraform/modules/ecr/outputs.tf b/terraform/modules/ecr/outputs.tf new file mode 100644 index 0000000..5016c1d --- /dev/null +++ b/terraform/modules/ecr/outputs.tf @@ -0,0 +1,7 @@ +output "ecr_repositories" { + description = "ECR repository URLs for each service" + value = { + for service, repo in aws_ecr_repository.msa_services : + service => repo.repository_url + } +} \ No newline at end of file diff --git a/terraform/modules/ecr/variables.tf b/terraform/modules/ecr/variables.tf new file mode 100644 index 0000000..a5041fa --- /dev/null +++ b/terraform/modules/ecr/variables.tf @@ -0,0 +1,12 @@ +variable "team_name" { + description = "Team name prefix for repositories" + type = string + default = "team1-mindscape" +} + +variable "services" { + description = "Services to create ECR repositories" + type = list(string) + default = ["auth", "info", "service"] + +} diff --git a/terraform/modules/eks/main.tf b/terraform/modules/eks/main.tf new file mode 100644 index 0000000..eafba47 --- /dev/null +++ b/terraform/modules/eks/main.tf @@ -0,0 +1,75 @@ +resource "aws_eks_cluster" "this" { + name = "${var.team_name}-eks-cluster" + role_arn = var.cluster_iam_role_arn + + vpc_config { + subnet_ids = var.subnet_ids + } + + tags = { + Name = "${var.team_name}-eks-cluster" + } +} + +resource "aws_eks_node_group" "ng" { + cluster_name = aws_eks_cluster.this.name + node_group_name = "${var.team_name}-ng" + node_role_arn = var.node_iam_role_arn + subnet_ids = var.subnet_ids + + instance_types = ["t3.large", "m5.large"] # ← 추가된 부분! + + scaling_config { + desired_size = 3 + min_size = 1 + max_size = 5 + } + + tags = { + Name = "${var.team_name}-ng" + } + launch_template { + id = aws_launch_template.ng_launch_template.id + version = "$Latest" + } +} + +# EKS 클러스터 생성 후 OIDC 정보 추출 +data "aws_eks_cluster" "cluster_data" { + name = aws_eks_cluster.this.name + depends_on = [aws_eks_cluster.this] +} + +data "tls_certificate" "eks" { + url = data.aws_eks_cluster.cluster_data.identity[0].oidc[0].issuer +} + +resource "aws_iam_openid_connect_provider" "this" { + url = data.aws_eks_cluster.cluster_data.identity[0].oidc[0].issuer + client_id_list = ["sts.amazonaws.com"] + thumbprint_list = [data.tls_certificate.eks.certificates[0].sha1_fingerprint] + +} + +# 노드 그룹을 위한 시작 템플릿 생성 +resource "aws_launch_template" "ng_launch_template" { + name = "${var.team_name}-ng-template" + + user_data = base64encode(<<-EOT + MIME-Version: 1.0 + Content-Type: multipart/mixed; boundary="==BOUNDARY==" + + --==BOUNDARY== + Content-Type: text/x-shellscript; charset="us-ascii" + + #!/bin/bash + # Set timezone to Asia/Seoul + rm -rf /etc/localtime + ln -s /usr/share/zoneinfo/Asia/Seoul /etc/localtime + # Restart chronyd service + systemctl restart chronyd + + --==BOUNDARY==-- + EOT + ) +} diff --git a/terraform/modules/eks/outputs.tf b/terraform/modules/eks/outputs.tf new file mode 100644 index 0000000..af4a339 --- /dev/null +++ b/terraform/modules/eks/outputs.tf @@ -0,0 +1,28 @@ +output "cluster_name" { + value = aws_eks_cluster.this.name +} + +output "node_group_name" { + value = aws_eks_node_group.ng.node_group_name +} + +output "cluster_endpoint" { + value = aws_eks_cluster.this.endpoint +} + +output "cluster_certificate_authority" { + value = aws_eks_cluster.this.certificate_authority[0].data +} + +output "oidc_url" { + value = aws_eks_cluster.this.identity[0].oidc[0].issuer +} + +output "oidc_provider_arn" { + value = aws_iam_openid_connect_provider.this.arn + +} + +output "cluster_security_group_id" { + value = aws_eks_cluster.this.vpc_config[0].cluster_security_group_id +} \ No newline at end of file diff --git a/terraform/modules/eks/variables.tf b/terraform/modules/eks/variables.tf new file mode 100644 index 0000000..f4416d6 --- /dev/null +++ b/terraform/modules/eks/variables.tf @@ -0,0 +1,25 @@ +variable "team_name" { + type = string + description = "Prefix for naming" +} + +variable "subnet_ids" { + type = list(string) + description = "Subnet IDs for EKS" +} + +variable "cluster_iam_role_arn" { + type = string + description = "IAM role ARN for EKS control plane" +} + +variable "node_iam_role_arn" { + type = string + description = "IAM role ARN for EKS node group" +} + +variable "bastion_role_arn" { + type = string + description = "IAM role ARN for bastion host" + default = "" +} diff --git a/terraform/modules/elasticache/main.tf b/terraform/modules/elasticache/main.tf new file mode 100644 index 0000000..2c94a15 --- /dev/null +++ b/terraform/modules/elasticache/main.tf @@ -0,0 +1,37 @@ +resource "aws_elasticache_subnet_group" "this" { + name = "team1-elasticache-subnet-group" + subnet_ids = var.subnet_ids +} + +resource "aws_security_group" "elasticache_sg" { + name = "team1-elasticache-sg" + description = "Security group for Redis" + vpc_id = var.vpc_id + + ingress { + from_port = 6379 + to_port = 6379 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] # TODO: private ip로 변경 + description = "Allow Redis access from private networks" + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_elasticache_cluster" "this" { + cluster_id = var.name + engine = "redis" + engine_version = var.engine_version + node_type = var.node_type + num_cache_nodes = var.num_nodes + parameter_group_name = var.parameter_group_name + port = var.port + subnet_group_name = aws_elasticache_subnet_group.this.name + security_group_ids = var.security_group_ids +} \ No newline at end of file diff --git a/terraform/modules/elasticache/outputs.tf b/terraform/modules/elasticache/outputs.tf new file mode 100644 index 0000000..2d7b56f --- /dev/null +++ b/terraform/modules/elasticache/outputs.tf @@ -0,0 +1,25 @@ + +output "port" { + description = "ElastiCache cluster port" + value = aws_elasticache_cluster.this.port +} + +output "cluster_id" { + description = "ElastiCache cluster ID" + value = aws_elasticache_cluster.this.id +} + +output "endpoint" { + description = "ElastiCache cluster endpoint" + value = aws_elasticache_cluster.this.cache_nodes[0].address +} + + +output "subnet_group_name" { + description = "ElastiCache subnet group name" + value = aws_elasticache_subnet_group.this.name +} + +output "elasticache_sg_id" { + value = aws_security_group.elasticache_sg.id +} \ No newline at end of file diff --git a/terraform/modules/elasticache/variables.tf b/terraform/modules/elasticache/variables.tf new file mode 100644 index 0000000..af6ae2d --- /dev/null +++ b/terraform/modules/elasticache/variables.tf @@ -0,0 +1,48 @@ +variable "name" { + description = "ElastiCache cluster name" + type = string + default = "team1-elasticache-cluster" +} +variable "vpc_id" { + description = "VPC ID for security group" + type = string +} +variable "subnet_ids" { + description = "List of subnet IDs for subnet group" + type = list(string) +} + +variable "security_group_ids" { + description = "List of security group IDs" + type = list(string) +} + +variable "node_type" { + description = "ElastiCache instance type" + type = string + default = "cache.m4.large" +} + +variable "num_nodes" { + description = "Number of cache nodes" + type = number + default = 1 +} + +variable "port" { + description = "Redis port" + type = number + default = 6379 +} + +variable "engine_version" { + description = "Redis engine version" + type = string + default = "7.0" +} + +variable "parameter_group_name" { + description = "Parameter group name" + type = string + default = "default.redis7" +} diff --git a/terraform/modules/iam/main.tf b/terraform/modules/iam/main.tf new file mode 100644 index 0000000..002e3cc --- /dev/null +++ b/terraform/modules/iam/main.tf @@ -0,0 +1,217 @@ +# eks cluster +resource "aws_iam_role" "eks_cluster" { + name = "${var.team_name}-eks-cluster-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Effect = "Allow" + Principal = { + Service = "eks.amazonaws.com" + } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "eks_cluster_attach" { + role = aws_iam_role.eks_cluster.name # eks_cluster 역할에 + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy" +} + +#node group +resource "aws_iam_role" "eks_node" { + name = "${var.team_name}-eks-node-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Effect = "Allow" + Principal = { + Service = "ec2.amazonaws.com" + } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "eks_worker_AmazonEKSWorkerNodePolicy" { + role = aws_iam_role.eks_node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy" +} + +resource "aws_iam_role_policy_attachment" "eks_cni_AmazonEKSCNIPolicy" { + role = aws_iam_role.eks_node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy" +} + +resource "aws_iam_role_policy_attachment" "eks_ec2_AmazonEC2ContainerRegistryReadOnly" { + role = aws_iam_role.eks_node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" +} + +#bastion +resource "aws_iam_role" "bastion" { + name = "${var.team_name}-bastion-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [ + { + Effect = "Allow", + Principal = { + Service = "ec2.amazonaws.com" + }, + Action = "sts:AssumeRole" + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "bastion_eks" { + role = aws_iam_role.bastion.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy" +} + +resource "aws_iam_role_policy_attachment" "bastion_ssm" { + role = aws_iam_role.bastion.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_role_policy_attachment" "bastion_ec2" { + role = aws_iam_role.bastion.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ReadOnlyAccess" +} + +resource "aws_iam_role_policy_attachment" "bastion_rds" { + role = aws_iam_role.bastion.name + policy_arn = "arn:aws:iam::aws:policy/AmazonRDSReadOnlyAccess" +} + +resource "aws_iam_role_policy_attachment" "bastion_elasticache" { + role = aws_iam_role.bastion.name + policy_arn = "arn:aws:iam::aws:policy/AmazonElastiCacheReadOnlyAccess" +} + +resource "aws_iam_role_policy_attachment" "bastion_cloudwatch" { + role = aws_iam_role.bastion.name + policy_arn = "arn:aws:iam::aws:policy/CloudWatchReadOnlyAccess" +} + +resource "aws_iam_instance_profile" "bastion" { + name = "${var.team_name}-bastion-profile" + role = aws_iam_role.bastion.name +} + +resource "aws_iam_role_policy_attachment" "bastion_eks_attach" { + role = aws_iam_role.bastion.name # bastion 역할에 + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy" +} + +# 1) 커스텀 읽기 전용 EKS 정책 생성 +resource "aws_iam_policy" "bastion_eks_readonly" { + name = "${var.team_name}-eks-bastion-readonly" + description = "Allow Bastion to describe and list EKS clusters" + + policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Effect = "Allow", + Action = [ + "eks:DescribeCluster", + "eks:ListClusters" + ], + Resource = "*" + }] + }) +} + +# 2) Bastion 역할에 방금 만든 정책 붙이기 +resource "aws_iam_role_policy_attachment" "bastion_eks_readonly_attach" { + role = aws_iam_role.bastion.name + policy_arn = aws_iam_policy.bastion_eks_readonly.arn +} + + + +# node group iam 권한 +resource "aws_iam_role_policy_attachment" "eks_node_amazon_ebs_csi_driver" { + role = aws_iam_role.eks_node.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonEBSCSIDriverPolicy" +} + +resource "aws_iam_role" "github_actions_oidc" { + name = "Team1-github-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Principal = { + Federated = "arn:aws:iam::194722398200:oidc-provider/token.actions.githubusercontent.com" + } + Action = "sts:AssumeRoleWithWebIdentity" + Condition = { + StringLike = { + "token.actions.githubusercontent.com:sub" = "repo:CLD-3rd/Final-Team1-Backend:*" + } + StringEquals = { + "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" + } + } + } + ] + }) +} + +resource "aws_iam_role_policy_attachment" "attach_admin" { + role = aws_iam_role.github_actions_oidc.name + policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess" +} + + +#-------------------- +# karpenter +resource "aws_iam_role" "karpenter_node" { + name = "KarpenterNodeRole-${var.cluster_name}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Effect = "Allow", + Principal = { Service = "ec2.amazonaws.com" }, + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_instance_profile" "karpenter_node" { + name = "KarpenterNodeInstanceProfile-${var.cluster_name}" + role = aws_iam_role.karpenter_node.name +} + +resource "aws_iam_role_policy_attachment" "karpenter_node_worker" { + role = aws_iam_role.karpenter_node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy" +} + +resource "aws_iam_role_policy_attachment" "karpenter_node_cni" { + role = aws_iam_role.karpenter_node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy" +} + +resource "aws_iam_role_policy_attachment" "karpenter_node_ssm" { + role = aws_iam_role.karpenter_node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" +} + +resource "aws_iam_role_policy_attachment" "karpenter_node_ecr" { + role = aws_iam_role.karpenter_node.name + policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly" +} + + +output "karpenter_instance_profile_name" { + value = aws_iam_instance_profile.karpenter_node.name +} diff --git a/terraform/modules/iam/outputs.tf b/terraform/modules/iam/outputs.tf new file mode 100644 index 0000000..e52d46b --- /dev/null +++ b/terraform/modules/iam/outputs.tf @@ -0,0 +1,33 @@ +output "eks_cluster_role_arn" { + value = aws_iam_role.eks_cluster.arn +} + +output "eks_node_role_arn" { + value = aws_iam_role.eks_node.arn +} + +output "bastion_role_arn" { + description = "ARN of the Bastion IAM Role" + value = aws_iam_role.bastion.arn +} + +output "bastion_instance_profile_name" { + value = aws_iam_instance_profile.bastion.name +} + + +#karpenter +output "karpenter_node_role_arn" { + value = aws_iam_role.karpenter_node.arn + description = "IAM Role ARN for Karpenter node" +} + +output "karpenter_node_instance_profile_name" { + value = aws_iam_instance_profile.karpenter_node.name + description = "Instance profile name for Karpenter node" +} + +output "bastion_role_name" { + description = "Name of the IAM role for the Bastion host" + value = aws_iam_role.bastion.name +} \ No newline at end of file diff --git a/terraform/modules/iam/variables.tf b/terraform/modules/iam/variables.tf new file mode 100644 index 0000000..b0d85c7 --- /dev/null +++ b/terraform/modules/iam/variables.tf @@ -0,0 +1,13 @@ +variable "team_name" { + type = string + description = "Prefix for naming" +} + +variable "cluster_name" { + description = "EKS cluster name" + type = string +} + +variable "oidc_url" { + type = string +} diff --git a/terraform/modules/irsa/alb/alb-controller-policy.json b/terraform/modules/irsa/alb/alb-controller-policy.json new file mode 100644 index 0000000..c79e577 --- /dev/null +++ b/terraform/modules/irsa/alb/alb-controller-policy.json @@ -0,0 +1,40 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "acm:DescribeCertificate", + "acm:ListCertificates", + "acm:GetCertificate", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:CreateSecurityGroup", + "ec2:CreateTags", + "ec2:DeleteTags", + "ec2:DeleteSecurityGroup", + "ec2:Describe*", + "ec2:ModifyInstanceAttribute", + "ec2:ModifyNetworkInterfaceAttribute", + "ec2:RevokeSecurityGroupIngress", + "elasticloadbalancing:*", + "iam:ListServerCertificates", + "iam:GetServerCertificate", + "waf-regional:GetWebACLForResource", + "waf-regional:GetWebACL", + "waf-regional:AssociateWebACL", + "waf-regional:DisassociateWebACL", + "wafv2:GetWebACLForResource", + "wafv2:GetWebACL", + "wafv2:AssociateWebACL", + "wafv2:DisassociateWebACL", + "shield:GetSubscriptionState", + "shield:DescribeProtection", + "shield:CreateProtection", + "shield:DeleteProtection", + "shield:DescribeSubscription", + "shield:ListProtections" + ], + "Resource": "*" + } + ] +} diff --git a/terraform/modules/irsa/alb/main.tf b/terraform/modules/irsa/alb/main.tf new file mode 100644 index 0000000..5a97faa --- /dev/null +++ b/terraform/modules/irsa/alb/main.tf @@ -0,0 +1,35 @@ +# alb controller irsa role +data "aws_iam_openid_connect_provider" "this" { + url = var.oidc_url +} + +resource "aws_iam_role" "alb_irsa" { + name = "${var.team_name}-alb-irsa-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Effect = "Allow", + Principal = { + Federated = data.aws_iam_openid_connect_provider.this.arn + }, + Action = "sts:AssumeRoleWithWebIdentity", + Condition = { + StringEquals = { + "${replace(var.oidc_url, "https://", "")}:sub" = "system:serviceaccount:kube-system:${var.service_account_name}" + } + } + }] + }) +} + +resource "aws_iam_policy" "alb_controller_policy" { + name = "${var.team_name}-alb-controller-policy" + description = "Policy for AWS ALB Ingress Controller" + policy = file("${path.module}/alb-controller-policy.json") +} + +resource "aws_iam_role_policy_attachment" "attach" { + role = aws_iam_role.alb_irsa.name + policy_arn = aws_iam_policy.alb_controller_policy.arn +} diff --git a/terraform/modules/irsa/alb/outputs.tf b/terraform/modules/irsa/alb/outputs.tf new file mode 100644 index 0000000..67e4e7f --- /dev/null +++ b/terraform/modules/irsa/alb/outputs.tf @@ -0,0 +1,10 @@ +output "alb_irsa_role_arn" { + description = "IAM Role ARN for ALB Controller ServiceAccount" + value = aws_iam_role.alb_irsa.arn +} + +# output "ebs_csi_irsa_role_arn" { +# description = "IAM Role ARN for EBS CSI Driver ServiceAccount" +# value = aws_iam_role.ebs_csi_irsa.arn +# } + diff --git a/terraform/modules/irsa/alb/variables.tf b/terraform/modules/irsa/alb/variables.tf new file mode 100644 index 0000000..7353dfa --- /dev/null +++ b/terraform/modules/irsa/alb/variables.tf @@ -0,0 +1,36 @@ +variable "team_name" { + description = "Team prefix for naming" + type = string +} + +variable "oidc_url" { + description = "OIDC provider URL from EKS cluster" + type = string +} + +variable "cluster_name" { + description = "Name of the EKS cluster" + type = string +} + +variable "namespace" { + description = "Kubernetes namespace where ALB controller runs" + type = string + default = "kube-system" +} + +variable "service_account_name" { + description = "ServiceAccount name for ALB controller" + type = string + default = "aws-load-balancer-controller" +} + +variable "create_alb_irsa" { + type = bool + default = false +} + +variable "create_ebs_csi_irsa" { + type = bool + default = false +} diff --git a/terraform/modules/irsa/ebs_csi/ebs-csi-policy.json b/terraform/modules/irsa/ebs_csi/ebs-csi-policy.json new file mode 100644 index 0000000..416c0e3 --- /dev/null +++ b/terraform/modules/irsa/ebs_csi/ebs-csi-policy.json @@ -0,0 +1,68 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "ec2:CreateSnapshot", + "ec2:AttachVolume", + "ec2:DetachVolume", + "ec2:ModifyVolume", + "ec2:DescribeAvailabilityZones", + "ec2:DescribeInstances", + "ec2:DescribeSnapshots", + "ec2:DescribeTags", + "ec2:DescribeVolumes", + "ec2:DescribeVolumesModifications", + "ec2:CreateTags", + "ec2:DeleteTags" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:CreateVolume" + ], + "Resource": "*", + "Condition": { + "StringEquals": { + "ec2:VolumeType": [ + "gp2", "gp3", "io1", "io2", "sc1", "st1", "standard" + ] + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:DeleteVolume" + ], + "Resource": "*" + }, + { + "Effect": "Allow", + "Action": [ + "ec2:CreateTags" + ], + "Resource": "arn:aws:ec2:*:*:volume/*", + "Condition": { + "StringEquals": { + "ec2:CreateAction": "CreateVolume" + } + } + }, + { + "Effect": "Allow", + "Action": [ + "ec2:CreateTags" + ], + "Resource": "arn:aws:ec2:*:*:snapshot/*", + "Condition": { + "StringEquals": { + "ec2:CreateAction": "CreateSnapshot" + } + } + } + ] +} \ No newline at end of file diff --git a/terraform/modules/irsa/ebs_csi/main.tf b/terraform/modules/irsa/ebs_csi/main.tf new file mode 100644 index 0000000..f4710ff --- /dev/null +++ b/terraform/modules/irsa/ebs_csi/main.tf @@ -0,0 +1,37 @@ +# ebs-csi irsa role +data "aws_iam_openid_connect_provider" "this" { + url = var.oidc_url +} + +### ebs-csi-driver irsa role +resource "aws_iam_role" "ebs_csi_irsa" { + name = "${var.team_name}-ebs-csi-irsa-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17", + Statement = [{ + Effect = "Allow", + Principal = { + Federated = data.aws_iam_openid_connect_provider.this.arn + }, + Action = "sts:AssumeRoleWithWebIdentity", + Condition = { + StringEquals = { + "${replace(var.oidc_url, "https://", "")}:sub" = "system:serviceaccount:kube-system:${var.service_account_name}" + } + } + }] + }) +} + +resource "aws_iam_policy" "ebs_csi_policy" { + name = "${var.team_name}-ebs-csi-policy" + description = "Policy for AWS EBS CSI Driver" + policy = file("${path.module}/ebs-csi-policy.json") +} + +resource "aws_iam_role_policy_attachment" "ebs_csi_attach" { + role = aws_iam_role.ebs_csi_irsa.name + policy_arn = aws_iam_policy.ebs_csi_policy.arn +} + diff --git a/terraform/modules/irsa/ebs_csi/outputs.tf b/terraform/modules/irsa/ebs_csi/outputs.tf new file mode 100644 index 0000000..629b4d7 --- /dev/null +++ b/terraform/modules/irsa/ebs_csi/outputs.tf @@ -0,0 +1,10 @@ +# output "alb_irsa_role_arn" { +# description = "IAM Role ARN for ALB Controller ServiceAccount" +# value = aws_iam_role.alb_irsa.arn +# } + +output "ebs_csi_irsa_role_arn" { + description = "IAM Role ARN for EBS CSI Driver ServiceAccount" + value = aws_iam_role.ebs_csi_irsa.arn +} + diff --git a/terraform/modules/irsa/ebs_csi/variables.tf b/terraform/modules/irsa/ebs_csi/variables.tf new file mode 100644 index 0000000..7353dfa --- /dev/null +++ b/terraform/modules/irsa/ebs_csi/variables.tf @@ -0,0 +1,36 @@ +variable "team_name" { + description = "Team prefix for naming" + type = string +} + +variable "oidc_url" { + description = "OIDC provider URL from EKS cluster" + type = string +} + +variable "cluster_name" { + description = "Name of the EKS cluster" + type = string +} + +variable "namespace" { + description = "Kubernetes namespace where ALB controller runs" + type = string + default = "kube-system" +} + +variable "service_account_name" { + description = "ServiceAccount name for ALB controller" + type = string + default = "aws-load-balancer-controller" +} + +variable "create_alb_irsa" { + type = bool + default = false +} + +variable "create_ebs_csi_irsa" { + type = bool + default = false +} diff --git a/terraform/modules/irsa/karpenter-controller/karpenter-controller-policy.json.tpl b/terraform/modules/irsa/karpenter-controller/karpenter-controller-policy.json.tpl new file mode 100644 index 0000000..c57704c --- /dev/null +++ b/terraform/modules/irsa/karpenter-controller/karpenter-controller-policy.json.tpl @@ -0,0 +1,112 @@ +{ + "Statement": [ + { + "Action": [ + "ssm:GetParameter", + "ec2:DescribeImages", + "ec2:RunInstances", + "ec2:DescribeSubnets", + "ec2:DescribeSecurityGroups", + "ec2:DescribeLaunchTemplates", + "ec2:DescribeInstances", + "ec2:DescribeInstanceTypes", + "ec2:DescribeInstanceTypeOfferings", + "ec2:DeleteLaunchTemplate", + "ec2:CreateTags", + "ec2:CreateLaunchTemplate", + "ec2:CreateFleet", + "ec2:DescribeSpotPriceHistory", + "pricing:GetProducts" + ], + "Effect": "Allow", + "Resource": "*", + "Sid": "Karpenter" + }, + { + "Action": "ec2:TerminateInstances", + "Condition": { + "StringLike": { + "ec2:ResourceTag/karpenter.sh/nodepool": "*" + } + }, + "Effect": "Allow", + "Resource": "*", + "Sid": "ConditionalEC2Termination" + }, + { + "Effect": "Allow", + "Action": "iam:PassRole", + "Resource": "arn:${AWS_PARTITION}:iam::${ACCOUNT_ID}:role/KarpenterNodeRole-${CLUSTER_NAME}", + "Sid": "PassNodeIAMRole" + }, + { + "Effect": "Allow", + "Action": "eks:DescribeCluster", + "Resource": "arn:${AWS_PARTITION}:eks:${AWS_DEFAULT_REGION}:${ACCOUNT_ID}:cluster/${CLUSTER_NAME}", + "Sid": "EKSClusterEndpointLookup" + }, + { + "Sid": "AllowScopedInstanceProfileCreationActions", + "Effect": "Allow", + "Resource": "*", + "Action": [ + "iam:CreateInstanceProfile" + ], + "Condition": { + "StringEquals": { + "aws:RequestTag/kubernetes.io/cluster/${CLUSTER_NAME}": "owned", + "aws:RequestTag/topology.kubernetes.io/region": "${AWS_DEFAULT_REGION}" + }, + "StringLike": { + "aws:RequestTag/karpenter.k8s.aws/ec2nodeclass": "*" + } + } + }, + { + "Sid": "AllowScopedInstanceProfileTagActions", + "Effect": "Allow", + "Resource": "*", + "Action": [ + "iam:TagInstanceProfile" + ], + "Condition": { + "StringEquals": { + "aws:ResourceTag/kubernetes.io/cluster/${CLUSTER_NAME}": "owned", + "aws:ResourceTag/topology.kubernetes.io/region": "${AWS_DEFAULT_REGION}", + "aws:RequestTag/kubernetes.io/cluster/${CLUSTER_NAME}": "owned", + "aws:RequestTag/topology.kubernetes.io/region": "${AWS_DEFAULT_REGION}" + }, + "StringLike": { + "aws:ResourceTag/karpenter.k8s.aws/ec2nodeclass": "*", + "aws:RequestTag/karpenter.k8s.aws/ec2nodeclass": "*" + } + } + }, + { + "Sid": "AllowScopedInstanceProfileActions", + "Effect": "Allow", + "Resource": "*", + "Action": [ + "iam:AddRoleToInstanceProfile", + "iam:RemoveRoleFromInstanceProfile", + "iam:DeleteInstanceProfile" + ], + "Condition": { + "StringEquals": { + "aws:ResourceTag/kubernetes.io/cluster/${CLUSTER_NAME}": "owned", + "aws:ResourceTag/topology.kubernetes.io/region": "${AWS_DEFAULT_REGION}" + }, + "StringLike": { + "aws:ResourceTag/karpenter.k8s.aws/ec2nodeclass": "*" + } + } + }, + { + "Sid": "AllowInstanceProfileReadActions", + "Effect": "Allow", + "Resource": "*", + "Action": "iam:GetInstanceProfile" + } + ], + "Version": "2012-10-17" +} \ No newline at end of file diff --git a/terraform/modules/irsa/karpenter-controller/main.tf b/terraform/modules/irsa/karpenter-controller/main.tf new file mode 100644 index 0000000..a068f96 --- /dev/null +++ b/terraform/modules/irsa/karpenter-controller/main.tf @@ -0,0 +1,52 @@ +data "aws_caller_identity" "current" {} + +resource "aws_iam_role" "karpenter_controller" { + name = "${var.team_name}-karpenter-controller-role" + + assume_role_policy = data.aws_iam_policy_document.karpenter_assume_role.json + + tags = { + Name = "${var.team_name}-karpenter-controller-role" + ManagedBy = "Terraform" + } + } + +data "aws_iam_policy_document" "karpenter_assume_role" { + statement { + effect = "Allow" + principals { + type = "Federated" + identifiers = [var.oidc_provider_arn] + } + + actions = ["sts:AssumeRoleWithWebIdentity"] + + condition { + test = "StringEquals" + variable = "${replace(var.oidc_provider_url, "https://", "")}:sub" + values = ["system:serviceaccount:kube-system:karpenter"] + } + + condition { + test = "StringEquals" + variable = "${replace(var.oidc_provider_url, "https://", "")}:aud" + values = ["sts.amazonaws.com"] + } + } +} + +resource "aws_iam_policy" "karpenter_controller" { + name = "${var.team_name}-karpenter-controller-policy" + description = "Karpenter controller policy" + policy = templatefile("${path.module}/karpenter-controller-policy.json.tpl", { + CLUSTER_NAME = var.cluster_name + AWS_DEFAULT_REGION = "ap-northeast-2" + ACCOUNT_ID = data.aws_caller_identity.current.account_id + AWS_PARTITION = "aws" + }) +} + +resource "aws_iam_role_policy_attachment" "karpenter_attach" { + role = aws_iam_role.karpenter_controller.name + policy_arn = aws_iam_policy.karpenter_controller.arn +} diff --git a/terraform/modules/irsa/karpenter-controller/outputs.tf b/terraform/modules/irsa/karpenter-controller/outputs.tf new file mode 100644 index 0000000..7088fc8 --- /dev/null +++ b/terraform/modules/irsa/karpenter-controller/outputs.tf @@ -0,0 +1,4 @@ +output "karpenter_controller_role_arn" { + value = aws_iam_role.karpenter_controller.arn + description = "IAM Role ARN for Karpenter Controller" +} diff --git a/terraform/modules/irsa/karpenter-controller/variables.tf b/terraform/modules/irsa/karpenter-controller/variables.tf new file mode 100644 index 0000000..65d6f3e --- /dev/null +++ b/terraform/modules/irsa/karpenter-controller/variables.tf @@ -0,0 +1,19 @@ +variable "team_name" { + type = string + description = "Prefix for naming resources" +} + +variable "oidc_provider_arn" { + type = string + description = "OIDC provider ARN for IRSA" +} + +variable "oidc_provider_url" { + type = string + description = "OIDC provider URL for IRSA (without https://)" +} + +variable "cluster_name" { + type = string + description = "EKS 클러스터 이름" +} \ No newline at end of file diff --git a/terraform/modules/karpenter/crd-install.tf b/terraform/modules/karpenter/crd-install.tf new file mode 100644 index 0000000..19a6d4b --- /dev/null +++ b/terraform/modules/karpenter/crd-install.tf @@ -0,0 +1,18 @@ +// modules/karpenter/crd-install.tf + +# resource "helm_release" "karpenter_crds" { +# provider = helm.eks +# name = "karpenter-crd" +# repository = "oci://public.ecr.aws/karpenter" +# chart = "karpenter-crd" +# version = var.karpenter_version +# namespace = var.namespace +# create_namespace = false +# skip_crds = false +# wait = true + + +# } + + + diff --git a/terraform/modules/karpenter/ec2nodeclass.yaml.tpl b/terraform/modules/karpenter/ec2nodeclass.yaml.tpl new file mode 100644 index 0000000..214c030 --- /dev/null +++ b/terraform/modules/karpenter/ec2nodeclass.yaml.tpl @@ -0,0 +1,15 @@ +apiVersion: karpenter.k8s.aws/v1 +kind: EC2NodeClass +metadata: + name: default +spec: + amiFamily: "AL2023" + amiSelectorTerms: + - ssmParameter: "/aws/service/eks/optimized-ami/1.33/amazon-linux-2023/x86_64/standard/recommended/image_id" + instanceProfile: "KarpenterNodeInstanceProfile-${cluster_name}" + subnetSelectorTerms: + - tags: + karpenter.sh/discovery: "${cluster_name}" + securityGroupSelectorTerms: + - tags: + karpenter.sh/discovery: "${cluster_name}" diff --git a/terraform/modules/karpenter/main.tf b/terraform/modules/karpenter/main.tf new file mode 100644 index 0000000..2cd310a --- /dev/null +++ b/terraform/modules/karpenter/main.tf @@ -0,0 +1,47 @@ +resource "helm_release" "karpenter" { + name = "karpenter" + repository = "oci://public.ecr.aws/karpenter" + chart = "karpenter" + version = var.karpenter_version + namespace = "kube-system" + create_namespace = false + skip_crds = false # CRDs 자동 설치 + wait = true + + + values = [ + yamlencode({ + settings = { + clusterName = var.cluster_name + clusterEndpoint = var.cluster_endpoint + } + serviceAccount = { + annotations = { + "eks.amazonaws.com/role-arn" = var.irsa_role_arn + } + name = "karpenter" + create = true # 여기 true로 변경!! + } + controller = { + resources = { + requests = { cpu = "1", memory = "1Gi" } + limits = { cpu = "1", memory = "1Gi" } + } + serviceAccount = { + name = "karpenter" + create = true + annotations = { + "eks.amazonaws.com/role-arn" = var.irsa_role_arn + } + automountServiceAccountToken = true # 여기로 이동해야 정상 작동 + } + podSecurityContext = { + fsGroup = 65534 + } + + } + }) +] +} + + diff --git a/terraform/modules/karpenter/node_resources.tf b/terraform/modules/karpenter/node_resources.tf new file mode 100644 index 0000000..fecbb1b --- /dev/null +++ b/terraform/modules/karpenter/node_resources.tf @@ -0,0 +1,19 @@ +# modules/karpenter/node_resources.tf + +resource "kubectl_manifest" "ec2nodeclass" { + # provider = kubectl.eks + depends_on = [helm_release.karpenter, var.namespace_dependency] + + yaml_body = templatefile("${path.module}/ec2nodeclass.yaml.tpl", { + cluster_name = var.cluster_name, + instance_profile = var.instance_profile, + }) +} + + +resource "kubectl_manifest" "nodepool" { + # provider = kubectl.eks + depends_on = [helm_release.karpenter, var.namespace_dependency] + + yaml_body = templatefile("${path.module}/nodepool.yaml.tpl", {}) +} diff --git a/terraform/modules/karpenter/nodepool.yaml.tpl b/terraform/modules/karpenter/nodepool.yaml.tpl new file mode 100644 index 0000000..c055a4f --- /dev/null +++ b/terraform/modules/karpenter/nodepool.yaml.tpl @@ -0,0 +1,35 @@ +apiVersion: karpenter.sh/v1 +kind: NodePool +metadata: + name: default + labels: + app.kubernetes.io/managed-by: terraform +spec: + template: + spec: + requirements: + - key: kubernetes.io/arch + operator: In + values: ["amd64"] + - key: kubernetes.io/os + operator: In + values: ["linux"] + - key: karpenter.sh/capacity-type + operator: In + values: ["spot"] + - key: karpenter.k8s.aws/instance-category + operator: In + values: ["c", "m", "r"] + - key: karpenter.k8s.aws/instance-generation + operator: Gt + values: ["2"] + nodeClassRef: + name: default + kind: EC2NodeClass + group: karpenter.k8s.aws + expireAfter: 720h + limits: + cpu: 1000 + disruption: + consolidationPolicy: WhenEmptyOrUnderutilized + consolidateAfter: 1m \ No newline at end of file diff --git a/terraform/modules/karpenter/outputs.tf b/terraform/modules/karpenter/outputs.tf new file mode 100644 index 0000000..1ea105c --- /dev/null +++ b/terraform/modules/karpenter/outputs.tf @@ -0,0 +1,3 @@ +output "karpenter_release_name" { + value = helm_release.karpenter.name +} diff --git a/terraform/modules/karpenter/rbac.tf b/terraform/modules/karpenter/rbac.tf new file mode 100644 index 0000000..915e0f6 --- /dev/null +++ b/terraform/modules/karpenter/rbac.tf @@ -0,0 +1,36 @@ +resource "kubernetes_cluster_role" "karpenter_iac_admin" { + metadata { + name = "karpenter-iac-admin" + } + + # nodepools (karpenter.sh) + rule { + api_groups = ["karpenter.sh"] + resources = ["nodepools"] + verbs = ["get", "list", "watch", "create", "update", "patch", "delete"] + } + + # ec2nodeclasses (karpenter.k8s.aws) + rule { + api_groups = ["karpenter.k8s.aws"] + resources = ["ec2nodeclasses"] + verbs = ["get", "list", "watch", "create", "update", "patch", "delete"] + } +} + + +resource "kubernetes_cluster_role_binding" "karpenter_iac_admin_binding" { + metadata { + name = "karpenter-iac-admin-binding" + } + subject { + kind = "Group" + name = "karpenter-iac-group" # aws_auth : mapUsers와 반드시 동일! + api_group = "rbac.authorization.k8s.io" + } + role_ref { + kind = "ClusterRole" + name = kubernetes_cluster_role.karpenter_iac_admin.metadata[0].name + api_group = "rbac.authorization.k8s.io" + } +} diff --git a/terraform/modules/karpenter/tagging.tf b/terraform/modules/karpenter/tagging.tf new file mode 100644 index 0000000..8536b2e --- /dev/null +++ b/terraform/modules/karpenter/tagging.tf @@ -0,0 +1,10 @@ +resource "aws_ec2_tag" "karpenter_subnet_tags" { + for_each = { + for idx, subnet_id in var.subnet_ids : + "subnet-${idx}" => subnet_id + } + + resource_id = each.value + key = "karpenter.sh/discovery" + value = var.cluster_name +} \ No newline at end of file diff --git a/terraform/modules/karpenter/variables.tf b/terraform/modules/karpenter/variables.tf new file mode 100644 index 0000000..5725d5b --- /dev/null +++ b/terraform/modules/karpenter/variables.tf @@ -0,0 +1,73 @@ +variable "cluster_name" { + type = string + description = "EKS 클러스터 이름" +} + +variable "namespace" { + type = string + default = "kube-system" + description = "Karpenter가 설치될 네임스페이스" +} + +variable "irsa_role_arn" { + type = string + description = "Karpenter Controller용 IRSA Role ARN" +} + + +# variable "oidc_provider_url" { +# type = string +# description = "OIDC provider URL for Helm chart values" +# } + +variable "cluster_endpoint" { + type = string + description = "EKS 클러스터의 엔드포인트 URL" +} + +variable "subnet_ids" { + type = list(string) + description = "List of subnet IDs for Karpenter discovery" +} + +variable "cluster_security_group_id" { + type = string + description = "EKS Cluster Security Group ID" +} + +variable "ubuntu_ami_id" { + type = string + description = "Ubuntu AMI ID to be used by Karpenter nodes" +} + + +# crd-nodepool.tf +# 필요한 변수들 + + + +variable "instance_profile" { +type = string +description = "IAM instance profile name for Karpenter nodes" +} + +variable "karpenter_version" { + type = string + default = "1.5.0" +} + +variable "bastion_host" { + type = string + description = "Bastion public IP or DNS" +} + +variable "bastion_user" { + type = string + description = "SSH user on Bastion (e.g., ec2-user)" +} + +variable "namespace_dependency" { + description = "Optional dependency on namespace module" + type = any + default = null +} \ No newline at end of file diff --git a/terraform/modules/karpenter/versions.tf b/terraform/modules/karpenter/versions.tf new file mode 100644 index 0000000..5c15cba --- /dev/null +++ b/terraform/modules/karpenter/versions.tf @@ -0,0 +1,18 @@ +terraform { + required_providers { + kubectl = { + source = "gavinbunney/kubectl" + version = "~> 1.14.0" + } + # 이미 사용 중일 수도 있는 프로바이더도 함께 명시해 주세요 + kubernetes = { + source = "hashicorp/kubernetes" + # 2.20.0 이상의 3.0.0 미만 버전을 모두 허용 + version = ">= 2.20.0, < 3.0.0" + } + helm = { + source = "hashicorp/helm" + version = "~> 3.0.0" + } + } +} \ No newline at end of file diff --git a/terraform/modules/monitoring/grafana/main.tf b/terraform/modules/monitoring/grafana/main.tf new file mode 100644 index 0000000..1e66bb5 --- /dev/null +++ b/terraform/modules/monitoring/grafana/main.tf @@ -0,0 +1,14 @@ +resource "helm_release" "grafana" { + #삭제 필요~ + # count = var.enabled ? 1 : 0 + name = "grafana" + repository = "https://grafana.github.io/helm-charts" + chart = "grafana" + namespace = var.namespace + version = var.chart_version + create_namespace = false + + values = [file("${path.module}/values.yaml")] # 커스터마이징이 필요하면 사용 + + depends_on = [var.namespace_dependency] +} diff --git a/terraform/modules/monitoring/grafana/outputs.tf b/terraform/modules/monitoring/grafana/outputs.tf new file mode 100644 index 0000000..89a4aef --- /dev/null +++ b/terraform/modules/monitoring/grafana/outputs.tf @@ -0,0 +1,6 @@ +output "grafana_name" { + + #value = var.enabled ? helm_release.grafana[0].name : null + value = helm_release.grafana.name + +} diff --git a/terraform/modules/monitoring/grafana/values.yaml b/terraform/modules/monitoring/grafana/values.yaml new file mode 100644 index 0000000..f05a5eb --- /dev/null +++ b/terraform/modules/monitoring/grafana/values.yaml @@ -0,0 +1,8 @@ +# grafana-values.yaml +adminPassword: "admin123!" + +grafana.ini: + server: + # ALB 호스트네임이 자동으로 들어가도록 변수 치환 + root_url: "%(protocol)s://%(domain)s/grafana" + serve_from_sub_path: true \ No newline at end of file diff --git a/terraform/modules/monitoring/grafana/variables.tf b/terraform/modules/monitoring/grafana/variables.tf new file mode 100644 index 0000000..6c60f55 --- /dev/null +++ b/terraform/modules/monitoring/grafana/variables.tf @@ -0,0 +1,22 @@ +variable "namespace" { + description = "Namespace to deploy Grafana" + type = string +} + +variable "chart_version" { + description = "Version of Grafana Helm chart" + type = string +} + +variable "namespace_dependency" { + description = "Optional dependency on namespace module" + type = any + default = null +} + +#삭제 필요~ +#variable "enabled" { +# description = "Enable or disable this module" +# type = bool +# default = true +#} diff --git a/terraform/modules/monitoring/grafana/versions.tf b/terraform/modules/monitoring/grafana/versions.tf new file mode 100644 index 0000000..d778da3 --- /dev/null +++ b/terraform/modules/monitoring/grafana/versions.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + helm = { + source = "hashicorp/helm" + version = ">= 2.12.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.20.0" + } + } +} diff --git a/terraform/modules/monitoring/prometheus/main.tf b/terraform/modules/monitoring/prometheus/main.tf new file mode 100644 index 0000000..1a0756e --- /dev/null +++ b/terraform/modules/monitoring/prometheus/main.tf @@ -0,0 +1,15 @@ +resource "helm_release" "prometheus" { + #삭제 필요~ + # count = var.enabled ? 1 : 0 + name = "prometheus" + repository = "https://prometheus-community.github.io/helm-charts" + chart = "kube-prometheus-stack" + namespace = var.namespace + version = var.chart_version + create_namespace = false + + +// values = [file("${path.module}/values.yaml")] # values.yaml 커스터마이징 필요시 주석 해제 + + depends_on = [var.namespace_dependency] +} diff --git a/terraform/modules/monitoring/prometheus/outputs.tf b/terraform/modules/monitoring/prometheus/outputs.tf new file mode 100644 index 0000000..425afe5 --- /dev/null +++ b/terraform/modules/monitoring/prometheus/outputs.tf @@ -0,0 +1,6 @@ +output "prometheus_name" { + #삭제 필요~ + #value = var.enabled ? helm_release.prometheus[0].name : null + value = helm_release.prometheus.name + +} diff --git a/terraform/modules/monitoring/prometheus/values.yaml b/terraform/modules/monitoring/prometheus/values.yaml new file mode 100644 index 0000000..443e938 --- /dev/null +++ b/terraform/modules/monitoring/prometheus/values.yaml @@ -0,0 +1,11 @@ +alertmanager: + persistentVolume: + enabled: true + storageClass: ebs-sc + size: 2Gi + +server: + persistentVolume: + enabled: true + storageClass: ebs-sc + size: 8Gi diff --git a/terraform/modules/monitoring/prometheus/variables.tf b/terraform/modules/monitoring/prometheus/variables.tf new file mode 100644 index 0000000..2fd4bbf --- /dev/null +++ b/terraform/modules/monitoring/prometheus/variables.tf @@ -0,0 +1,22 @@ +variable "namespace" { + description = "Namespace to deploy Prometheus" + type = string +} + +variable "chart_version" { + description = "Version of Prometheus Helm chart" + type = string +} + +variable "namespace_dependency" { + description = "Dependency to wait for namespace module" + type = any + default = null +} + +#삭제 필요~ +#variable "enabled" { +# description = "Enable or disable this module" +# type = bool +# default = true +#} diff --git a/terraform/modules/monitoring/prometheus/versions.tf b/terraform/modules/monitoring/prometheus/versions.tf new file mode 100644 index 0000000..d778da3 --- /dev/null +++ b/terraform/modules/monitoring/prometheus/versions.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + helm = { + source = "hashicorp/helm" + version = ">= 2.12.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.20.0" + } + } +} diff --git a/terraform/modules/namespace/main.tf b/terraform/modules/namespace/main.tf new file mode 100644 index 0000000..032aa44 --- /dev/null +++ b/terraform/modules/namespace/main.tf @@ -0,0 +1,8 @@ +resource "kubernetes_namespace" "this" { + provider = kubernetes.eks + + metadata { + name = var.name + labels = var.labels + } +} \ No newline at end of file diff --git a/terraform/modules/namespace/outputs.tf b/terraform/modules/namespace/outputs.tf new file mode 100644 index 0000000..d67df4b --- /dev/null +++ b/terraform/modules/namespace/outputs.tf @@ -0,0 +1,3 @@ +output "name" { + value = kubernetes_namespace.this.metadata[0].name +} diff --git a/terraform/modules/namespace/variables.tf b/terraform/modules/namespace/variables.tf new file mode 100644 index 0000000..726d41a --- /dev/null +++ b/terraform/modules/namespace/variables.tf @@ -0,0 +1,10 @@ +variable "name" { + description = "The name of the namespace to create" + type = string +} + +variable "labels" { + description = "Optional labels for the namespace" + type = map(string) + default = {} +} diff --git a/terraform/modules/namespace/versions.tf b/terraform/modules/namespace/versions.tf new file mode 100644 index 0000000..4aa53c1 --- /dev/null +++ b/terraform/modules/namespace/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.20.0" + configuration_aliases = [kubernetes.eks] + } + } +} \ No newline at end of file diff --git a/terraform/modules/network/internet-gateway/main.tf b/terraform/modules/network/internet-gateway/main.tf new file mode 100644 index 0000000..d93ff40 --- /dev/null +++ b/terraform/modules/network/internet-gateway/main.tf @@ -0,0 +1,8 @@ +resource "aws_internet_gateway" "this" { + vpc_id = var.vpc_id + + tags = { + Name = "${var.name_prefix}-igw" + Environment = var.environment + } +} \ No newline at end of file diff --git a/terraform/modules/network/internet-gateway/outputs.tf b/terraform/modules/network/internet-gateway/outputs.tf new file mode 100644 index 0000000..7dea444 --- /dev/null +++ b/terraform/modules/network/internet-gateway/outputs.tf @@ -0,0 +1,4 @@ +output "igw_id" { + description = "Internet Gateway ID" + value = aws_internet_gateway.this.id +} diff --git a/terraform/modules/network/internet-gateway/variables.tf b/terraform/modules/network/internet-gateway/variables.tf new file mode 100644 index 0000000..7df1a3f --- /dev/null +++ b/terraform/modules/network/internet-gateway/variables.tf @@ -0,0 +1,14 @@ +variable "vpc_id" { + description = "연결할 VPC ID" + type = string +} + +variable "name_prefix" { + description = "리소스 이름 접두어" + type = string +} + +variable "environment" { + description = "환경 (dev, prod 등)" + type = string +} diff --git a/terraform/modules/network/nat-gateway/main.tf b/terraform/modules/network/nat-gateway/main.tf new file mode 100644 index 0000000..5b34960 --- /dev/null +++ b/terraform/modules/network/nat-gateway/main.tf @@ -0,0 +1,39 @@ +resource "aws_eip" "this" { + domain = "vpc" + + tags = { + Name = "${var.name_prefix}-nat-eip" + Environment = var.environment + } +} + +resource "aws_nat_gateway" "this" { + allocation_id = aws_eip.this.id + subnet_id = var.public_subnet_id + depends_on = [var.igw_id] # IGW가 먼저 있어야 NAT이 동작함 + + tags = { + Name = "${var.name_prefix}-nat-gw" + Environment = var.environment + } +} + +resource "aws_route_table" "private" { + vpc_id = var.vpc_id + + route { + cidr_block = "0.0.0.0/0" + nat_gateway_id = aws_nat_gateway.this.id + } + + tags = { + Name = "${var.name_prefix}-private-rt" + Environment = var.environment + } +} + +resource "aws_route_table_association" "private" { + count = length(var.private_subnet_ids) + subnet_id = var.private_subnet_ids[count.index] + route_table_id = aws_route_table.private.id +} diff --git a/terraform/modules/network/nat-gateway/outputs.tf b/terraform/modules/network/nat-gateway/outputs.tf new file mode 100644 index 0000000..6eedc55 --- /dev/null +++ b/terraform/modules/network/nat-gateway/outputs.tf @@ -0,0 +1,9 @@ +output "nat_gateway_id" { + value = aws_nat_gateway.this.id + description = "NAT Gateway ID" +} + +output "private_route_table_id" { + value = aws_route_table.private.id + description = "Private Route Table ID" +} diff --git a/terraform/modules/network/nat-gateway/variables.tf b/terraform/modules/network/nat-gateway/variables.tf new file mode 100644 index 0000000..8dfc290 --- /dev/null +++ b/terraform/modules/network/nat-gateway/variables.tf @@ -0,0 +1,29 @@ +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "igw_id" { + description = "Internet Gateway ID" + type = string +} + +variable "public_subnet_id" { + description = "NAT Gateway가 위치할 Public Subnet ID (1개)" + type = string +} + +variable "private_subnet_ids" { + description = "Private Subnet ID 리스트" + type = list(string) +} + +variable "name_prefix" { + description = "리소스 이름 접두어" + type = string +} + +variable "environment" { + description = "환경 (dev, prod 등)" + type = string +} diff --git a/terraform/modules/network/route-table/main.tf b/terraform/modules/network/route-table/main.tf new file mode 100644 index 0000000..6f4f2a5 --- /dev/null +++ b/terraform/modules/network/route-table/main.tf @@ -0,0 +1,19 @@ +resource "aws_route_table" "public" { + vpc_id = var.vpc_id + + route { + cidr_block = "0.0.0.0/0" + gateway_id = var.igw_id + } + + tags = { + Name = "${var.name_prefix}-public-rt" + Environment = var.environment + } +} + +resource "aws_route_table_association" "public" { + count = length(var.public_subnet_ids) + subnet_id = var.public_subnet_ids[count.index] + route_table_id = aws_route_table.public.id +} diff --git a/terraform/modules/network/route-table/outputs.tf b/terraform/modules/network/route-table/outputs.tf new file mode 100644 index 0000000..e8dfda3 --- /dev/null +++ b/terraform/modules/network/route-table/outputs.tf @@ -0,0 +1,4 @@ +output "route_table_id" { + description = "Public Route Table ID" + value = aws_route_table.public.id +} diff --git a/terraform/modules/network/route-table/variables.tf b/terraform/modules/network/route-table/variables.tf new file mode 100644 index 0000000..b524a1a --- /dev/null +++ b/terraform/modules/network/route-table/variables.tf @@ -0,0 +1,24 @@ +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "igw_id" { + description = "Internet Gateway ID" + type = string +} + +variable "public_subnet_ids" { + description = "Public Subnet ID 리스트" + type = list(string) +} + +variable "name_prefix" { + description = "리소스 이름 접두어" + type = string +} + +variable "environment" { + description = "환경 (dev, prod 등)" + type = string +} diff --git a/terraform/modules/network/subnet/main.tf b/terraform/modules/network/subnet/main.tf new file mode 100644 index 0000000..413f1ef --- /dev/null +++ b/terraform/modules/network/subnet/main.tf @@ -0,0 +1,31 @@ +resource "aws_subnet" "public" { + count = length(var.public_subnet_cidrs) + vpc_id = var.vpc_id + cidr_block = var.public_subnet_cidrs[count.index] + availability_zone = var.azs[count.index] + map_public_ip_on_launch = true + + tags = { + Name = "${var.name_prefix}-public-${count.index}" + Environment = var.environment + Tier = "public" + # ALB Controller용 태그 + "kubernetes.io/role/elb" = "1" + "kubernetes.io/cluster/Team1-backend-eks-cluster" = "shared" + } +} + +resource "aws_subnet" "private" { + count = length(var.private_subnet_cidrs) + vpc_id = var.vpc_id + cidr_block = var.private_subnet_cidrs[count.index] + availability_zone = element(var.azs, count.index % length(var.azs)) + + tags = { + Name = "${var.name_prefix}-private-${count.index}" + Environment = var.environment + Tier = "private" + type = count.index < length(var.azs) ? "app" : "data" + "karpenter.sh/discovery" = "Team1-backend-eks-cluster" + } +} diff --git a/terraform/modules/network/subnet/outputs.tf b/terraform/modules/network/subnet/outputs.tf new file mode 100644 index 0000000..0da261b --- /dev/null +++ b/terraform/modules/network/subnet/outputs.tf @@ -0,0 +1,9 @@ +output "public_subnet_ids" { + value = aws_subnet.public[*].id + description = "Public Subnet ID 리스트" +} + +output "private_subnet_ids" { + value = aws_subnet.private[*].id + description = "Private Subnet ID 리스트" +} diff --git a/terraform/modules/network/subnet/variables.tf b/terraform/modules/network/subnet/variables.tf new file mode 100644 index 0000000..1dcd454 --- /dev/null +++ b/terraform/modules/network/subnet/variables.tf @@ -0,0 +1,30 @@ +variable "vpc_id" { + description = "VPC ID" + type = string +} + +variable "name_prefix" { + description = "리소스 이름 접두어" + type = string +} + +variable "environment" { + description = "환경 (dev, prod 등)" + type = string +} + +variable "public_subnet_cidrs" { + description = "Public Subnet CIDR 리스트" + type = list(string) +} + +variable "private_subnet_cidrs" { + description = "Private Subnet CIDR 리스트" + type = list(string) +} + +variable "azs" { + description = "사용할 가용영역 리스트" + type = list(string) +} + diff --git a/terraform/modules/network/vpc/main.tf b/terraform/modules/network/vpc/main.tf new file mode 100644 index 0000000..9a302cd --- /dev/null +++ b/terraform/modules/network/vpc/main.tf @@ -0,0 +1,10 @@ +resource "aws_vpc" "main" { + cidr_block = var.vpc_cidr + enable_dns_support = true + enable_dns_hostnames = true + + tags = { + Name = "${var.name_prefix}-vpc" + Environment = var.environment + } +} \ No newline at end of file diff --git a/terraform/modules/network/vpc/outputs.tf b/terraform/modules/network/vpc/outputs.tf new file mode 100644 index 0000000..b867edd --- /dev/null +++ b/terraform/modules/network/vpc/outputs.tf @@ -0,0 +1,4 @@ +output "vpc_id" { + description = "생성된 VPC의 ID" + value = aws_vpc.main.id +} \ No newline at end of file diff --git a/terraform/modules/network/vpc/variables.tf b/terraform/modules/network/vpc/variables.tf new file mode 100644 index 0000000..b14c094 --- /dev/null +++ b/terraform/modules/network/vpc/variables.tf @@ -0,0 +1,14 @@ +variable "name_prefix" { + description = "리소스 이름 접두어 (ex: Team1-backend)" + type = string +} + +variable "environment" { + description = "환경 (dev, prod 등)" + type = string +} + +variable "vpc_cidr" { + description = "VPC의 CIDR 블록" + type = string +} \ No newline at end of file diff --git a/terraform/modules/rds/.DS_Store b/terraform/modules/rds/.DS_Store new file mode 100644 index 0000000..fb6b27a Binary files /dev/null and b/terraform/modules/rds/.DS_Store differ diff --git a/terraform/modules/rds/main.tf b/terraform/modules/rds/main.tf new file mode 100644 index 0000000..77aada1 --- /dev/null +++ b/terraform/modules/rds/main.tf @@ -0,0 +1,42 @@ +resource "aws_db_subnet_group" "rds" { + name = "team1-rds-subnet_group" + subnet_ids = var.db_subnet_ids +} + +resource "aws_security_group" "rds" { + name = "team1-rds-sg" + description = "Allow RDS access" + vpc_id = var.vpc_id + + ingress { + from_port = 3306 + to_port = 3306 + protocol = "tcp" + cidr_blocks = var.allowed_cidrs + } + + egress { + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } +} + +resource "aws_db_instance" "rds" { + identifier = var.identifier + engine = var.engine + engine_version = var.engine_version + instance_class = var.instance_class + allocated_storage = var.allocated_storage + + db_name = var.db_name + username = var.db_username + password = var.db_password + + db_subnet_group_name = aws_db_subnet_group.rds.name + vpc_security_group_ids = [aws_security_group.rds.id] + + skip_final_snapshot = true + publicly_accessible = false +} \ No newline at end of file diff --git a/terraform/modules/rds/outputs.tf b/terraform/modules/rds/outputs.tf new file mode 100644 index 0000000..2e964df --- /dev/null +++ b/terraform/modules/rds/outputs.tf @@ -0,0 +1,24 @@ +output "rds_endpoint" { + value = aws_db_instance.rds.endpoint +} + +output "rds_port" { + description = "The port of the RDS instance" + value = aws_db_instance.rds.port +} + + +output "rds_db_name" { + description = "The name of the default database" + value = aws_db_instance.rds.db_name +} + +output "rds_username" { + description = "The master username for the RDS instance" + value = aws_db_instance.rds.username +} + +output "rds_security_group_id" { + description = "The security group ID attached to the RDS instance" + value = aws_security_group.rds.id +} \ No newline at end of file diff --git a/terraform/modules/rds/variables.tf b/terraform/modules/rds/variables.tf new file mode 100644 index 0000000..a10dd1c --- /dev/null +++ b/terraform/modules/rds/variables.tf @@ -0,0 +1,61 @@ +variable "vpc_id" { + description = "VPC ID for RDS" + type = string +} + +variable "db_subnet_ids" { + description = "Subnets for RDS subnet group" + type = list(string) +} + +variable "allowed_cidrs" { + description = "CIDR blocks allowed to access RDS" + type = list(string) + default = ["0.0.0.0/0"] +} + +variable "db_name" { + description = "Initial database name" + type = string + default = "mindscape" +} + +variable "db_username" { + description = "Master username for RDS" + type = string + default = "root" +} + +variable "db_password" { + description = "Master password for RDS" + type = string + sensitive = true +} + +variable "identifier" { + description = "rds identifier" + default = "team1-rds-identifier" +} + +variable "engine" { + description = "Database engine" + type = string + default = "mysql" +} + +variable "engine_version" { + description = "Database engine version" + type = string + default = "8.0" +} + +variable "instance_class" { + description = "RDS instance class" + type = string + default = "db.m7g.large" +} +variable "allocated_storage" { + description = "Initial allocated storage in GB" + type = number + default = 20 +} diff --git a/terraform/modules/security-group/main.tf b/terraform/modules/security-group/main.tf new file mode 100644 index 0000000..94aaea5 --- /dev/null +++ b/terraform/modules/security-group/main.tf @@ -0,0 +1,90 @@ +# Bastion EC2 인스턴스를 위한 보안 그룹 +resource "aws_security_group" "bastion" { + name = "${var.team_name}-bastion-sg" + description = "Security group for Bastion host" + vpc_id = var.vpc_id + + ingress { + description = "Allow SSH from anywhere" + from_port = 22 + to_port = 22 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + +# InfluxDB HTTP API (8086) 허용 룰 추가 + ingress { + description = "Allow InfluxDB HTTP API" + from_port = 8086 + to_port = 8086 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + } + + egress { + description = "Allow all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.team_name}-bastion-sg" + } +} + +# EKS 노드 그룹을 위한 보안 그룹 +resource "aws_security_group" "eks_node" { + name = "${var.team_name}-eks-node-sg" + description = "Security group for EKS worker nodes" + vpc_id = var.vpc_id + + ingress { + description = "Allow all traffic between nodes" + from_port = 0 + to_port = 0 + protocol = "-1" + self = true + } + + egress { + description = "Allow all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.team_name}-eks-node-sg" + "karpenter.sh/discovery" = var.cluster_name + } +} + +# EKS 컨트롤 플레인을 위한 보안 그룹 +resource "aws_security_group" "eks_control_plane" { + name = "${var.team_name}-eks-control-sg" + description = "Security group for EKS control plane" + vpc_id = var.vpc_id + + ingress { + description = "Allow Bastion to connect to EKS API server" + from_port = 443 + to_port = 443 + protocol = "tcp" + security_groups = [aws_security_group.bastion.id] + } + + egress { + description = "Allow all outbound traffic" + from_port = 0 + to_port = 0 + protocol = "-1" + cidr_blocks = ["0.0.0.0/0"] + } + + tags = { + Name = "${var.team_name}-eks-control-sg" + } +} diff --git a/terraform/modules/security-group/outputs.tf b/terraform/modules/security-group/outputs.tf new file mode 100644 index 0000000..c71f514 --- /dev/null +++ b/terraform/modules/security-group/outputs.tf @@ -0,0 +1,17 @@ +# Bastion 보안 그룹 ID +output "bastion_sg_id" { + description = "Security Group ID for Bastion host" + value = aws_security_group.bastion.id +} + +# EKS 워커 노드 보안 그룹 ID +output "eks_node_sg_id" { + description = "Security Group ID for EKS worker nodes" + value = aws_security_group.eks_node.id +} + +# Bastion에서 EKS API 서버로 접근 허용용 SG ID +output "eks_control_plane_sg_id" { + description = "Security Group ID to allow Bastion to access EKS Control Plane" + value = aws_security_group.eks_control_plane.id +} diff --git a/terraform/modules/security-group/variables.tf b/terraform/modules/security-group/variables.tf new file mode 100644 index 0000000..141a608 --- /dev/null +++ b/terraform/modules/security-group/variables.tf @@ -0,0 +1,16 @@ +# 사용할 VPC ID +variable "vpc_id" { + description = "ID of the VPC" + type = string +} + +# 팀 이름 prefix +variable "team_name" { + description = "Team prefix for naming" + type = string +} + +variable "cluster_name" { + type = string + description = "EKS 클러스터 이름" +} \ No newline at end of file diff --git a/terraform/modules/storageclass/main.tf b/terraform/modules/storageclass/main.tf new file mode 100644 index 0000000..c3b6227 --- /dev/null +++ b/terraform/modules/storageclass/main.tf @@ -0,0 +1,20 @@ +resource "kubernetes_storage_class_v1" "ebs_sc" { + provider = kubernetes.eks + + metadata { + name = var.name + } + + storage_provisioner = "ebs.csi.aws.com" + + parameters = { + type = var.volume_type + fsType = var.fs_type + } + + reclaim_policy = var.reclaim_policy + volume_binding_mode = var.binding_mode + allow_volume_expansion = true + + mount_options = var.mount_options +} diff --git a/terraform/modules/storageclass/outputs.tf b/terraform/modules/storageclass/outputs.tf new file mode 100644 index 0000000..e69de29 diff --git a/terraform/modules/storageclass/variables.tf b/terraform/modules/storageclass/variables.tf new file mode 100644 index 0000000..9f6d849 --- /dev/null +++ b/terraform/modules/storageclass/variables.tf @@ -0,0 +1,35 @@ +variable "name" { + description = "The name of the StorageClass" + type = string + default = "ebs-sc" +} + +variable "volume_type" { + description = "The type of EBS volume (e.g., gp3, gp2, io1)" + type = string + default = "gp3" +} + +variable "fs_type" { + description = "The file system type to use (e.g., ext4, xfs)" + type = string + default = "ext4" +} + +variable "reclaim_policy" { + description = "Reclaim policy for the volume (e.g., Retain, Delete)" + type = string + default = "Delete" +} + +variable "binding_mode" { + description = "Volume binding mode" + type = string + default = "WaitForFirstConsumer" +} + +variable "mount_options" { + description = "List of mount options" + type = list(string) + default = [] +} diff --git a/terraform/modules/storageclass/versions.tf b/terraform/modules/storageclass/versions.tf new file mode 100644 index 0000000..cf4fd49 --- /dev/null +++ b/terraform/modules/storageclass/versions.tf @@ -0,0 +1,9 @@ +terraform { + required_providers { + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.20.0" + configuration_aliases = [kubernetes.eks] + } + } +} diff --git a/terraform/outputs.tf b/terraform/outputs.tf new file mode 100644 index 0000000..617ae58 --- /dev/null +++ b/terraform/outputs.tf @@ -0,0 +1,90 @@ +output "vpc_id" { + value = module.vpc.vpc_id +} + +# EKS 클러스터 이름 (kubectl 연결, Helm, 모니터링 등에서 사용) +output "cluster_name" { + value = module.eks.cluster_name +} + +# EKS 클러스터 엔드포인트 (kubectl 접속 시 필요) +output "cluster_endpoint" { + value = module.eks.cluster_endpoint +} + +# EKS 클러스터 인증서 (kubeconfig 설정 시 필요) +output "cluster_certificate_authority" { + value = module.eks.cluster_certificate_authority +} + +# EKS 노드 그룹 이름 (노드 그룹 확인, 확장 등 관리에 사용) +output "node_group_name" { + value = module.eks.node_group_name +} + +# Bastion EC2 인스턴스 ID (AWS 콘솔이나 CLI에서 Bastion 추적용) +output "bastion_instance_id" { + value = module.bastion.bastion_instance_id +} + +# Bastion EC2의 공인 IP (SSH 또는 SSM으로 접속 시 필요) +output "bastion_public_ip" { + value = module.bastion.bastion_public_ip +} + +# Bastion EC2의 사설 IP (VPC 내부 접근 시 사용) +output "bastion_private_ip" { + value = module.bastion.bastion_private_ip +} + +# EKS 클러스터용 IAM Role ARN (클러스터 생성 시 사용) +output "eks_cluster_role_arn" { + value = module.iam.eks_cluster_role_arn +} + +# EKS 노드용 IAM Role ARN (노드 그룹 생성 시 사용) +output "eks_node_role_arn" { + value = module.iam.eks_node_role_arn +} + +# Bastion EC2 IAM Role ARN (SSM, EKS, EC2, RDS 등 접근 권한) +output "bastion_role_arn" { + value = module.iam.bastion_role_arn +} + +# Bastion EC2 IAM 인스턴스 프로파일 이름 (EC2에 Role 연결 시 사용) +output "bastion_instance_profile_name" { + value = module.iam.bastion_instance_profile_name +} + + +# 보안그룹 +output "bastion_sg_id" { + value = module.sg.bastion_sg_id + description = "Bastion EC2의 보안 그룹 ID" +} + +output "eks_node_sg_id" { + value = module.sg.eks_node_sg_id + description = "EKS 워커 노드 보안 그룹 ID" +} + +output "eks_control_plane_sg_id" { + value = module.sg.eks_control_plane_sg_id + description = "Bastion에서 EKS Control Plane 접근 허용 SG ID" +} + + +# ECR repo +output "ecr_repositories" { + value = module.ecr.ecr_repositories +} + + +output "rds_endpoint" { + value = module.rds.rds_endpoint +} + +output "redis_endpoint" { + value = module.elasticache.endpoint +} diff --git a/terraform/providers.tf b/terraform/providers.tf new file mode 100644 index 0000000..3ae6b49 --- /dev/null +++ b/terraform/providers.tf @@ -0,0 +1,81 @@ +terraform { + required_providers { + helm = { + source = "hashicorp/helm" + version = ">= 2.12.0" + } + kubernetes = { + source = "hashicorp/kubernetes" + version = ">= 2.20.0" + } + aws = { + source = "hashicorp/aws" + version = ">= 5.0.0" + } + kubectl = { + source = "gavinbunney/kubectl" + version = "~> 1.14.0" # 원하시는 버전으로 조정하세요 + } + } +} + +provider "aws" { + region = "ap-northeast-2" +} + +data "aws_eks_cluster" "eks" { + name = module.eks.cluster_name + depends_on = [module.eks] +} + +data "aws_eks_cluster_auth" "eks" { + name = module.eks.cluster_name + depends_on = [module.eks] +} + +provider "kubernetes" { + alias = "eks" + host = module.eks.cluster_endpoint + cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority) + token = data.aws_eks_cluster_auth.eks.token + exec { + api_version = "client.authentication.k8s.io/v1beta1" + command = "aws" + args = ["eks", "get-token", "--cluster-name", module.eks.cluster_name] + } + + + +} + +# Helm Provider +provider "helm" { + + alias = "eks" + kubernetes = { + host = module.eks.cluster_endpoint + cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority) + token = data.aws_eks_cluster_auth.eks.token + } + +} + +# # 1) default kubectl 프로바이더 선언 +# provider "kubectl" { +# load_config_file = true +# } + +# # 2) bastion alias 프로바이더 선언 +# provider "kubectl" { +# alias = "bastion" +# config_path = "/root/.kube/config" +# } + +provider "kubectl" { + alias = "eks" + host = module.eks.cluster_endpoint + cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority) + token = data.aws_eks_cluster_auth.eks.token + load_config_file = false +} + diff --git a/terraform/variables.tf b/terraform/variables.tf new file mode 100644 index 0000000..2f2906e --- /dev/null +++ b/terraform/variables.tf @@ -0,0 +1,25 @@ +variable "team_name" { + description = "Name of team name" + type = string + default = "Team1-backend" +} + +variable "ami_id" { + description = "AMI ID for Bastion EC2" + type = string +} + +variable "bastion_key_name" { + description = "Name of the existing EC2 key pair for Bastion SSH access" + type = string +} + + + +variable "db_password" { + description = "Master password for RDS" + type = string + sensitive = true + default = "root1234" +} + diff --git a/test.txt b/test.txt new file mode 100644 index 0000000..8bd6648 --- /dev/null +++ b/test.txt @@ -0,0 +1 @@ +asdf