diff --git a/.github/workflows/deploy-to-test-server.yml b/.github/workflows/deploy-to-test-server.yml deleted file mode 100644 index ef64d03..0000000 --- a/.github/workflows/deploy-to-test-server.yml +++ /dev/null @@ -1,80 +0,0 @@ -name: Build and Deploy to Test Server - -on: - push: - branches: - - develop # develop 브랜치 push 시 실행 - workflow_dispatch: - -permissions: - contents: write -jobs: - build: - runs-on: ubuntu-latest - - steps: - # 1. 코드 체크아웃 - - name: Checkout code - uses: actions/checkout@v4 - - # 2. JDK 세팅 (Spring 빌드) - - name: Set up JDK 21 - uses: actions/setup-java@v4 - with: - distribution: 'temurin' - java-version: '21' - - # 3. application-dev.yml 주입 (빌드 시점에만 포함, Docker 이미지 안에 들어감) - - name: Create application-dev.yml from Secret - run: | - mkdir -p src/main/resources - cat <<'EOF' > src/main/resources/application-dev.yml - ${{ secrets.SPRING_APPLICATION_DEV_YML }} - EOF - - # 4. Gradle 빌드 - - name: Build with Gradle - run: ./gradlew clean build - - # 5. QEMU 설치 (multi-arch 지원) - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - # 6. Buildx 설치 - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - # 7. Docker Hub 로그인 - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.TEST_SERVER_DOCKER_HUB_USERNAME }} - password: ${{ secrets.TEST_SERVER_DOCKER_HUB_ACCESS_TOKEN }} - - # 8. 멀티 아키텍처 이미지 빌드 & 푸시 - - name: Build and push Docker image (multi-arch) - uses: docker/build-push-action@v5 - with: - context: . - file: ./Dockerfile - push: true - platforms: linux/amd64,linux/arm64 - tags: | - dhxxn502/redot_test_server:latest - dhxxn502/redot_test_server:${{ github.sha }} - - # 9. GitOps repo 업데이트 (deployment.yaml 이미지 태그 갱신) - - name: Checkout GitOps repo - uses: actions/checkout@v4 - with: - repository: eraser502/gitops-repo # GitOps repo 주소 - token: ${{ secrets.GH_PAT }} # Personal Access Token 필요 - - - name: Update image in deployment.yaml - run: | - sed -i "s|dhxxn502/redot_test_server:.*|dhxxn502/redot_test_server:${{ github.sha }}|" apps/redot-test/deployment.yaml - git config --global user.name "github-actions" - git config --global user.email "actions@github.com" - git add apps/redot-test/deployment.yaml - git commit -m "Update redot-test image to ${{ github.sha }}" || echo "No changes to commit" - git push diff --git a/.github/workflows/publish-homelab-image.yml b/.github/workflows/publish-homelab-image.yml new file mode 100644 index 0000000..5f5c60c --- /dev/null +++ b/.github/workflows/publish-homelab-image.yml @@ -0,0 +1,72 @@ +name: Publish homelab image +on: + workflow_dispatch: +permissions: + contents: read +concurrency: + group: homelab-image-${{ github.ref }} + cancel-in-progress: false +jobs: + publish: + if: github.repository == 'redotlabs/server' && github.ref == 'refs/heads/develop' + runs-on: ubuntu-24.04 + timeout-minutes: 35 + permissions: + contents: read + packages: write + env: + IMAGE: ghcr.io/redotlabs/server-homelab + DOCKER_BUILD_SUMMARY: 'false' + DOCKER_BUILD_RECORD_UPLOAD: 'false' + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + - name: PostgreSQL concurrency regression tests + run: ./gradlew clean test + - name: Isolated tests, Flyway/Hibernate validation and secret-free JAR + run: python3 scripts/test-kubernetes.py + - name: Verify packaged configuration allowlist + run: python3 scripts/check-kubernetes-jar.py + - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Refuse to overwrite a published commit tag + shell: bash + run: | + set -euo pipefail + tag="$IMAGE:sha-$GITHUB_SHA" + if docker buildx imagetools inspect "$tag" > /dev/null 2> lookup-error.txt; then + echo 'This commit is already published; use its existing digest.' >&2 + exit 1 + fi + # Authentication/network failures must not be mistaken for an absent tag. + if ! grep -Eqi 'manifest unknown|not found' lookup-error.txt; then + echo 'Cannot establish tag absence; stop without publishing.' >&2 + exit 1 + fi + rm lookup-error.txt + - name: Build and publish amd64 image + id: image + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: . + file: Dockerfile.kubernetes + platforms: linux/amd64 + push: true + tags: ghcr.io/redotlabs/server-homelab:sha-${{ github.sha }} + labels: | + org.opencontainers.image.source=https://github.com/redotlabs/server + org.opencontainers.image.revision=${{ github.sha }} + provenance: mode=min + - name: Record deployment reference (not credentials) + env: + IMAGE_DIGEST: ${{ steps.image.outputs.digest }} + run: printf '%s@%s\n' "$IMAGE" "$IMAGE_DIGEST" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6bd22c..f7bc4bf 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,7 @@ permissions: jobs: test: runs-on: ubuntu-latest - timeout-minutes: 15 + timeout-minutes: 35 steps: - uses: actions/checkout@v4 - uses: actions/setup-java@v4 @@ -19,6 +19,10 @@ jobs: java-version: '21' - name: Build and test with disposable PostgreSQL run: ./gradlew clean build + - name: Homelab application and runtime validation + run: python3 scripts/test-kubernetes.py + - name: Verify packaged configuration allowlist + run: python3 scripts/check-kubernetes-jar.py - name: Upload test reports if: always() uses: actions/upload-artifact@v4 diff --git a/.gitignore b/.gitignore index a8cbe51..d118f47 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ __pycache__/ devtools/ devtools/** + +# Public runtime template only; never commit actual environment-specific credentials. +!src/main/resources/application-homelab.yml diff --git a/DEPLOY.md b/DEPLOY.md index 2e63920..e44a5f7 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -1 +1,11 @@ +# 배포 경로 + +새 홈서버는 `eraser502/homelab`을 GitOps 기준으로 사용한다. 소스 통합 후 검증·수동 이미지 발행·GitOps PR·수동 Sync 순서는 [홈랩 발행 문서](docs/HOMELAB-IMAGE-PUBLISHING.md)를 따른다. + +기존 develop 테스트 서버 자동 배포는 폐기했다. main의 기존 EC2 workflow는 별도이며 유지한다. + +## 기존 기록 + - 251130 deploy test + +Kubernetes 배포 준비는 [별도 문서](docs/KUBERNETES-PREPARATION.md)를 따른다. 기존 EC2 workflow의 image는 runtime 설정을 포함하므로 새 홈랩 image로 재사용하지 않는다. diff --git a/Dockerfile.kubernetes b/Dockerfile.kubernetes new file mode 100644 index 0000000..7dc6f94 --- /dev/null +++ b/Dockerfile.kubernetes @@ -0,0 +1,8 @@ +# Built and tested by python3 scripts/test-kubernetes.py. +# Pin the linux/amd64 runtime manifest; first deployment is N100 only. +FROM eclipse-temurin:21-jre@sha256:628467024d428251da635661936d6180717b1ac1119d41c76d28a03b7293c43c +WORKDIR /app +COPY --chown=10001:10001 build/kubernetes/app.jar /app/app.jar +USER 10001:10001 +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "/app/app.jar"] diff --git a/Dockerfile.kubernetes.dockerignore b/Dockerfile.kubernetes.dockerignore new file mode 100644 index 0000000..2c06f83 --- /dev/null +++ b/Dockerfile.kubernetes.dockerignore @@ -0,0 +1,6 @@ +** +!Dockerfile.kubernetes +!Dockerfile.kubernetes.dockerignore +!build/ +!build/kubernetes/ +!build/kubernetes/app.jar diff --git a/README.md b/README.md index c773e19..7bedd5f 100644 --- a/README.md +++ b/README.md @@ -1 +1,5 @@ # Redot Server + +Kubernetes 배포 준비와 런타임 변수 계약: [KUBERNETES-PREPARATION](docs/KUBERNETES-PREPARATION.md). 아직 배포하지 않았으며 기존 production과 분리된 경로다. + +홈랩 전용 이미지 발행 준비: [HOMELAB-IMAGE-PUBLISHING.md](docs/HOMELAB-IMAGE-PUBLISHING.md). 아직 commit/push 및 배포하지 않았습니다. diff --git a/build.gradle b/build.gradle index 072ac04..bdac4af 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-database-postgresql' compileOnly 'org.projectlombok:lombok' @@ -75,7 +76,7 @@ dependencies { } tasks.named('test') { - useJUnitPlatform { excludeTags 'experiment' } + useJUnitPlatform { excludeTags 'integration', 'experiment' } } // Opt-in local service/DB measurement; no production endpoint is contacted. @@ -88,3 +89,44 @@ tasks.register('inquiryExperiment', Test) { systemProperty 'experiment.output', providers.gradleProperty('experimentOutput').getOrElse('build/inquiry-experiment') systemProperty 'experiment.variant', providers.gradleProperty('experimentVariant').getOrElse('fixed') } + +tasks.register('integrationTest', Test) { + group = 'verification' + description = 'Run full application/Flyway tests against isolated PostgreSQL and Redis' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { includeTags 'integration' } + shouldRunAfter tasks.named('test') +} + + +// Kubernetes packaging is separate from legacy prod/dev workflows. +// Only reviewed, credential-free configuration and migrations enter this image. +def kubernetesResources = tasks.register('kubernetesResources', Sync) { + from('src/main/resources') { + include 'application.yml', 'application-homelab.yml', 'db/migration/*.sql' + } + into layout.buildDirectory.dir('kubernetes-resources') +} + +tasks.register('kubernetesBootJar', org.springframework.boot.gradle.tasks.bundling.BootJar) { + group = 'build' + description = 'Build an environment-independent JAR without legacy injected profile files' + dependsOn tasks.named('classes'), kubernetesResources + archiveFileName = 'app.jar' + destinationDirectory = layout.buildDirectory.dir('kubernetes') + mainClass = 'redot.redot_server.RedotServerApplication' + targetJavaVersion = JavaVersion.VERSION_21 + classpath = sourceSets.main.runtimeClasspath.filter { + it != sourceSets.main.output.resourcesDir + } + files(kubernetesResources.map { it.destinationDir }) + preserveFileTimestamps = false + reproducibleFileOrder = true +} + +// Container-backed tests run on the host; this task needs no Docker socket. +tasks.register('homelabUnitTest', Test) { + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform { excludeTags 'integration', 'experiment', 'container' } +} diff --git a/docs/HOMELAB-IMAGE-PUBLISHING.md b/docs/HOMELAB-IMAGE-PUBLISHING.md new file mode 100644 index 0000000..89fcb06 --- /dev/null +++ b/docs/HOMELAB-IMAGE-PUBLISHING.md @@ -0,0 +1,67 @@ +# Homelab 이미지 발행 + +## 2026-10-14 통합 이후 절차 + +- 소스 기준: `develop`. 문의번호 동시성 수정과 기존 Kubernetes 준비 브랜치를 통합한다. +- PR 검증: 호스트 Java 21 + Docker에서 `./gradlew clean build`, 이어서 `python3 scripts/test-kubernetes.py`와 `python3 scripts/check-kubernetes-jar.py`. +- `test`는 PostgreSQL Testcontainers 회귀 테스트와 홈랩 단위 테스트를 실행한다. `homelabUnitTest`는 Docker 소켓이 없는 테스트 컨테이너에서 실행할 단위 테스트만 선택한다. `integrationTest`는 실제 임시 PostgreSQL/Redis에 연결한 homelab 프로필을 검증한다. 두 경로를 합쳐야 전체 검증이다. +- 이미지 발행: `Publish homelab image`를 `develop`에서 **수동 실행**한다. PR 생성·develop push/merge는 이미지 발행이나 배포를 실행하지 않는다. 현재 GitHub 기본 브랜치는 develop으로 확인했다. PR 병합 후 해당 브랜치에서 수동 실행할 수 있다. +- 발행 workflow도 양쪽 테스트 및 공개 리소스 허용목록을 통과해야 GHCR에 `sha-`을 게시한다. 애플리케이션 비밀값 없이 빌드한다. +- 그 다음 `eraser502/homelab`의 `applications/redot/resources.yaml`에서 검증된 image tag와 registry digest를 갱신하는 별도 PR을 준비한다. Argo CD 수동 Sync 후 readiness·문의 생성/조회·로그를 확인한다. +- 예전 develop → Docker Hub → `eraser502/gitops-repo/apps/redot-test` 배포 workflow는 제거한다. main의 EC2 workflow는 이번 범위에 포함하지 않는다. +- 이번 통합 작업 범위는 테스트와 이슈·PR까지다. 실제 이미지 발행, GitOps digest 변경, Sync와 운영 데이터 쓰기는 후속 배포 단계다. +- CI의 일반 `test` 보고서만 업로드한다. 임시 DB/Redis 연결 정보를 포함할 수 있는 homelab 통합 보고서는 로컬/runner에만 둔다. + +아래는 최초 준비 당시의 **2026-09-27 이력**이다. 당시의 미배포·브랜치·테스트 수·승인 단계는 현재 상태를 나타내지 않는다. + +--- + +# Homelab 이미지 발행 준비 + +2026-09-27: 파일 작성/로컬 검증만 완료. commit/push/image publish는 미실행. + +- 별도 workflow: `.github/workflows/publish-homelab-image.yml`. +- 초기 배포 브랜치: `prepare/kubernetes-runtime`. main/develop의 기존 EC2/test workflow는 그대로다. +- 승인된 Registry/image: GHCR `ghcr.io/redotlabs/server-homelab`, Private. Organization의 Private package 생성 허용 여부는 아직 확인되지 않았으며 승인 후 실제 최초 publish 결과로 검증한다. +- push trigger는 준비 브랜치만. workflow_dispatch는 준비 브랜치/main으로 제한하지만 default branch에 workflow가 있어야 UI 사용 가능하다. 최초는 준비 브랜치 push 방식. +- Test: `python3 scripts/test-kubernetes.py`. 고정 PostgreSQL/Redis의 임시 Docker fixture로 17개 테스트/Flyway/Hibernate 및 Redis AOF 검증 후 `kubernetesBootJar` 생성. +- `python3 scripts/check-kubernetes-jar.py`로 JAR resource allowlist/공개 소스 일치를 검사. +- Dockerfile.kubernetes, amd64, non-root UID10001, pinned Java21 JRE. +- GHCR publish는 job의 GITHUB_TOKEN contents:read/packages:write. 기존 repository secret YAML이나 DockerHub/EC2 credential을 참조하지 않는다. +- tag는 sha-전체commit. 이미 있는 tag는 덮어쓰기 거부. registry 조회 실패 시 발행 중단. +- registry 자체 불변성을 가정하지 않는다. Homelab에는 발행된 `:sha-@sha256:`를 사용한다. local image ID/JAR SHA를 registry digest 대신 넣지 않는다. +- Private package를 유지한다. Kubernetes pull credential은 실제 package 생성 후에만 준비한다. package Read 권한 계정의 classic PAT read:packages + SOPS Secret을 사용한다. 별도 publish PAT는 생성하지 않는다. Argo SSH key는 image pull credential이 아니다. +- DB/OAuth/JWT/SMTP/AWS/Discord 값은 JAR/image/Actions secret YAML에서 분리하여 Kubernetes runtime에만 주입한다. +- workflow나 이미지에는 실제 application credential이 필요 없다. Secret은 image build-arg로도 전달하지 않는다. +- test logs/reports는 runtime fixture 정보가 들어갈 수 있어 자동 artifact 업로드하지 않는다. Buildx build record 업로드도 비활성화했다. + +로컬 검증: unit12/integration5 통과, Docker build 통과, packaging allowlist 통과, actionlint1.7.12 통과. 실제 GitHub workflow/registry push는 승인 전 미실행이다. + +Homelab 상세 설계는 homelab repo의 `docs/19-redot-image-gitops-preparation.md`와 `applications/redot`에 있다. frontend는 아직 미확정이다. Google callback은 `https://redot-api.dhxxn.kr/api/v1/sign-in/redot-member/social/callback/redot-member-google`. + +다음 단계는 파일 리뷰 후 Application repository commit/push 및 그에 따른 최초 publish 승인이다. 현재는 commit/push 직전에 멈춘다. 준비 브랜치 push는 publish workflow를 자동 실행하므로 push와 image publish를 별개인 것처럼 취급하지 않는다. 이 workflow는 Homelab Git을 수정하거나 Kubernetes에 접근하지 않는다. + + +## 승인된 실행 순서와 담당 (2026-09-27) + +현재 단계에서는 모든 실제 credential 입력을 보류한다. frontend origin/redirect도 미정 상태다. + +| 순서 | 담당 | 수행할 작업 | Secret 입력 | +|---|---|---|---| +| 1 | Codex | 로컬 브랜치/17개 테스트/JAR/image/workflow 최종 검사와 변경 보고 후 중단 | 없음 | +| 2 | 사용자 | Application repo commit/push와 이에 따른 최초 image publish를 명시적으로 승인 | 없음 | +| 3 | Codex | 승인된 파일만 commit. 인증이 이미 제공된 범위면 준비 브랜치에 push; 인증 부재 시 사용자 N100 push로 인계 | Codex가 값을 받지 않음 | +| 4 | 사용자 N100 터미널 | 인계받은 경우에만 git push -u origin prepare/kubernetes-runtime. 개인 terminal의 Git 인증 prompt에서 입력; credential을 command line/history/docs에 넣지 않음 | Git push 인증이 필요할 때만 | +| 5 | GitHub Actions / Codex | GITHUB_TOKEN으로 최초 발행 시도. Actions 결과와 오류를 확인해 org package 생성 권한을 실제 검증. SHA/digest/amd64 확인 | publish PAT 불필요 | +| 6 | 사용자 GitHub UI | 성공하면 Package settings에서 Private, redotlabs/server 연결/Actions 접근 확인. package 생성 정책 오류면 조직 Settings→Packages→Package Creation의 Private 허용을 관리자가 수정. 기존 package 접근 오류면 Manage Actions access에서 repository Write 확인 | 없음 | +| 7 | Codex | 실패 시 credential을 추가하는 대신 원인별 수정 계획 제시. 성공 시 실제 package read 권한/visibility 확인 및 pull 준비. 권한 오류 때 재실행도 사용자와 조율 | 없음 | +| 8 | 사용자 GitHub UI | package 생성 성공 후 pull 계정의 package Read 접근을 부여하고 classic PAT read:packages 생성, 필요한 SSO 승인 | PAT 생성; 채팅 공유 금지 | +| 9 | Codex / 사용자 N100 터미널 | 그 시점에 registry 전용 숨김 입력 절차를 준비한 뒤 사용자 입력→SOPS pull Secret 파일 준비→안전한 pull 인증 검증 | pull credential만 | +| 10 | 사용자 / Codex | 별도 단계에서 frontend 일반 설정과 application credential을 준비하고 runtime 설정 검증 | Google/SMTP/AWS 등은 이 단계까지 보류 | +| 11 | 사용자 / Codex | Homelab 변경 review 및 별도 commit/push 승인. Kubernetes 등록/Sync는 다시 별도 배포 승인 후 | 기존 SOPS 파일 사용 | + +현재 기존 `prepare-redot-secrets.py`는 application credential과 pull credential을 함께 묻는다. 지금 실행하지 않는다. package 생성 후 pull만 먼저 준비할 때는 registry 전용 입력 절차를 별도로 준비한다. 장기 Git push PAT와 registry pull PAT도 용도/권한을 혼동하지 않는다. + +GitHub UI에서 workflow를 수동 실행하거나 기존 EC2 workflow를 실행할 필요는 없다. 첫 준비 브랜치 push가 신규 workflow의 실행 계기다. 워크플로우는 private visibility를 강제로 설정하는 API 작업을 하지 않으므로 실제 package 설정은 최초 발행 후 확인한다. package가 이미 존재한다면 그 visibility/접근 권한 확인이 필요하며 public으로 된 기존 package를 private이라고 간주하지 않는다. + +최종 검사에서 Gradle UP-TO-DATE 재사용을 발견하여 홈랩 검증 스크립트에 `--rerun-tasks`를 추가했다. 임시 DB/Redis가 매번 새로 생성되므로 테스트도 매번 실제 실행한다. 이전 XML 존재만으로 신규 DB migration 검증을 주장하지 않는다. 기존 EC2 workflow 명령에는 영향이 없다. diff --git a/docs/KUBERNETES-PREPARATION.md b/docs/KUBERNETES-PREPARATION.md new file mode 100644 index 0000000..6b7ae80 --- /dev/null +++ b/docs/KUBERNETES-PREPARATION.md @@ -0,0 +1,174 @@ +> 이 문서는 2026-09-27 준비 이력을 포함한다. 2026-10-14 이후 소스·테스트·게시 절차는 [현행 발행 절차](HOMELAB-IMAGE-PUBLISHING.md)를 따른다. `test`에 실제 PostgreSQL Testcontainers 회귀 테스트가 추가됐으므로 전체 검증에는 호스트 Java 21과 Docker가 필요하다. 스크립트 단독 실행은 전체 문의번호 회귀 테스트를 대신하지 않는다. + +# Kubernetes 배포 준비 (로컬 검증 완료, commit/push 대기) + +기준: main c8fa281f40c3cb02b107b9e30a10e84f69b1df90. namespace `redot`, API `redot-api.dhxxn.kr`. 아직 commit/push/K3s 배포하지 않았다. 기존 production 운영값은 읽지 않는다. 신규 DB이므로 사용자 후속 지시에 따라 과거 서버 버전 추적을 중단하고 PostgreSQL 17.11을 선정했다. + +## 기존 production과 분리 + +기존 `Dockerfile`, Compose, EC2/develop workflow, production profile Secret은 이번에 수정하지 않는다. `SPRING_PROFILES_ACTIVE=homelab`을 사용하는 새 경로만 준비한다. profile group이 prod CookieProvider를 선택하되 Kubernetes JAR에는 기존 주입 application-prod.yml 자체를 넣지 않는다. 기존 prod/dev에서는 새 Actuator 공개가 기본 차단된다. 기존 API SecurityFilterChain과 OAuth/비즈니스 로직/SQL은 수정하지 않았다. + +main과 develop 차이는 CookieProviderDev의 헤더 로그 제거뿐이다. main+dev profile은 request header를 기록할 수 있으므로 홈랩에서는 dev를 활성화하지 않는다. 해당 코드 제거는 별도 변경으로 남겼다. PostgreSQL 버전 전체 이력 조사에서 발견한 1.20.1/42.7.3은 각각 Testcontainers/JDBC 라이브러리 버전이며 서버 버전 근거가 아니다. + +## 새 빌드 경로 + +Java 21과 Gradle wrapper 8.14.3을 사용한다. wrapper distribution SHA-256을 고정했다. + +```bash +./gradlew test # 외부 DB 없는 테스트 +python3 scripts/test-kubernetes.py # 실제 임시 DB/Redis + 모든 테스트 + kubernetesBootJar +# 위 검증이 모두 통과한 후에만 실제 배포용 image 발행 단계로 진행 +# 아래는 로컬 빌드 예시이며 push 명령이 아님 +docker build --platform linux/amd64 -f Dockerfile.kubernetes -t redot-server:local-validation . +``` + +`kubernetesBootJar`는 `build/kubernetes/app.jar`를 만든다. main class와 classpath를 명시하고 기존 processResources 출력 대신 allowlist로 모은 `application.yml`, `application-homelab.yml`, `db/migration/*.sql`만 포함한다. legacy 주입 profile, .env, 기타 임의 resource를 포함하지 않는다. 향후 앱에 새 정적 resource가 필요하면 검토 후 allowlist에 추가한다. archive 순서와 timestamp를 고정했지만 supply-chain attestation/모든 dependency 장기 재현성을 보장하는 의미는 아니다. + +Kubernetes 전용 Dockerfile은 amd64 Java21 JRE manifest digest를 고정하고 UID/GID10001, 명시적인 `/app/app.jar`, exec-form Java 실행을 사용한다. build stage를 추가하지 않았다. Dockerfile별 .dockerignore는 해당 JAR와 Dockerfile만 context에 허용한다. 기존 CI의 build→JAR→image 구조를 유지하면서 분리했다. 현재 기존 workflow는 여전히 legacy profile을 image에 포함하므로 **그 image를 홈랩에 재사용하지 않는다**. 신규 image 발행 전에 workflow의 새 경로 전환/테스트/기존 배포 trigger 분리를 승인받아야 한다. + +credential은 image ARG/ENV/JVM option에 넣지 않는다. 런타임 Kubernetes SOPS Secret에서 주입한다. `JAVA_TOOL_OPTIONS`는 JVM이 로그에 출력할 수 있으므로 memory/timezone 등 비민감 옵션에만 사용한다. + +## 런타임 변수 계약 + +아래는 실제 추가한 `application-homelab.yml`의 placeholder 전부다. 값은 저장하지 않았다. `${...}`가 필수인 항목은 사용자 안전 입력/설정 확정 전 정상 애플리케이션 startup을 보장하지 않는다. PostgreSQL image는 docs/runtime-images.json에 17.11과 digest를 고정했다. + +| 환경변수 | Spring property | 필수/기본 | Kubernetes 관리 | +|---|---|---|---| +| `SERVER_PORT` | `server.port` | 선택 (기본 8080) | ConfigMap | +| `FORWARD_HEADERS_STRATEGY` | `server.forward-headers-strategy` | 선택 (기본 framework) | ConfigMap | +| `DB_HOST` | `spring.datasource.url` | 필수 | ConfigMap | +| `DB_PORT` | `spring.datasource.url` | 선택 (기본 5432) | ConfigMap | +| `DB_NAME` | `spring.datasource.url` | 필수 | ConfigMap | +| `DB_USERNAME` | `spring.datasource.username` | 필수 | ConfigMap | +| `DB_PASSWORD` | `spring.datasource.password` | 필수 | SOPS Secret | +| `DB_POOL_SIZE` | `spring.datasource.hikari.maximum-pool-size` | 선택 (기본 5) | ConfigMap | +| `REDIS_HOST` | `spring.data.redis.host` | 필수 | ConfigMap | +| `REDIS_PORT` | `spring.data.redis.port` | 선택 (기본 6379) | ConfigMap | +| `REDIS_PASSWORD` | `spring.data.redis.password` | 필수 | SOPS Secret | +| `SMTP_HOST` | `spring.mail.host` | 필수 | ConfigMap | +| `SMTP_PORT` | `spring.mail.port` | 선택 (기본 587) | ConfigMap | +| `SMTP_USERNAME` | `spring.mail.username` | 필수 | SOPS Secret | +| `SMTP_PASSWORD` | `spring.mail.password` | 필수 | SOPS Secret | +| `SMTP_AUTH` | `spring.mail.properties.mail.smtp.auth` | 선택 (기본 true) | ConfigMap | +| `SMTP_STARTTLS_ENABLE` | `spring.mail.properties.mail.smtp.starttls.enable` | 선택 (기본 true) | ConfigMap | +| `SMTP_STARTTLS_REQUIRED` | `spring.mail.properties.mail.smtp.starttls.required` | 선택 (기본 true) | ConfigMap | +| `SMTP_SSL_ENABLE` | `spring.mail.properties.mail.smtp.ssl.enable` | 선택 (기본 false) | ConfigMap | +| `OAUTH2_BASE_URL` | `spring.security.oauth2.base-url` | 선택 (기본 https://redot-api.dhxxn.kr) | ConfigMap | +| `GOOGLE_CLIENT_ID` | `spring.security.oauth2.client.registration.redot-member-google.client-id` | 필수 | SOPS Secret | +| `GOOGLE_CLIENT_SECRET` | `spring.security.oauth2.client.registration.redot-member-google.client-secret` | 필수 | SOPS Secret | +| `GOOGLE_REDIRECT_URI` | `spring.security.oauth2.client.registration.redot-member-google.redirect-uri` | 필수 | ConfigMap | +| `GOOGLE_SCOPES` | `spring.security.oauth2.client.registration.redot-member-google.scope` | 선택 (기본 openid,profile,email) | ConfigMap | +| `JWT_SECRET` | `jwt.secret` | 필수 | SOPS Secret | +| `JWT_COOKIE_DOMAIN` | `jwt.cookie.domain` | 선택 (기본 redot-api.dhxxn.kr) | ConfigMap | +| `JWT_COOKIE_SAME_SITE` | `jwt.cookie.same-site` | 선택 (기본 Lax) | ConfigMap | +| `AWS_REGION` | `cloud.aws.region.static` | 필수 | ConfigMap | +| `AWS_ACCESS_KEY_ID` | `cloud.aws.credentials.access-key` | 필수 | SOPS Secret | +| `AWS_SECRET_ACCESS_KEY` | `cloud.aws.credentials.secret-key` | 필수 | SOPS Secret | +| `S3_BUCKET` | `cloud.aws.s3.bucket` | 필수 | ConfigMap | +| `IMAGE_CDN_BASE_URL` | `image.cdn-base-url` | 선택 (기본 빈 값) | ConfigMap | +| `IMAGE_MAX_SIZE_BYTES` | `image.upload.max-size-bytes` | 선택 (기본 5242880) | ConfigMap | +| `IMAGE_ALLOWED_CONTENT_TYPES` | `image.upload.allowed-content-types` | 선택 (기본 image/*) | ConfigMap | +| `MAIL_FROM` | `email.verification.from-address` | 필수 | ConfigMap | +| `MAIL_BRAND_NAME` | `email.verification.brand-name` | 선택 (기본 Redot) | ConfigMap | +| `EMAIL_CODE_LENGTH` | `email.verification.code-length` | 선택 (기본 6) | ConfigMap | +| `EMAIL_CODE_TTL_SECONDS` | `email.verification.code-ttl-seconds` | 선택 (기본 300) | ConfigMap | +| `EMAIL_TOKEN_TTL_SECONDS` | `email.verification.token-ttl-seconds` | 선택 (기본 900) | ConfigMap | +| `EMAIL_RESEND_COOLDOWN_SECONDS` | `email.verification.resend-cooldown-seconds` | 선택 (기본 5) | ConfigMap | +| `AUTH_SUCCESS_REDIRECT` | `auth.flows.redot-member.default-success-redirect` | 필수 | ConfigMap | +| `AUTH_FAILURE_REDIRECT` | `auth.flows.redot-member.default-failure-redirect` | 필수 | ConfigMap | +| `AUTH_ALLOWED_REDIRECT_HOSTS` | `auth.flows.redot-member.allowed-redirect-hosts` | 필수 | ConfigMap | +| `ADMIN_CONSOLE_BASE_URL` | `notifications.consultation.admin-console-base-url` | 선택 (기본 빈 값) | ConfigMap | +| `DISCORD_WEBHOOK_URL` | `notifications.consultation.discord.webhook-url` | 선택 (기본 빈 값) | SOPS Secret | +| `CORS_ALLOWED_ORIGINS` | `redot.cors.allowed-origins` | 필수 | ConfigMap | +| `LOG_LEVEL` | `logging.level.root` | 선택 (기본 INFO) | ConfigMap | + +추가 환경변수: `SPRING_PROFILES_ACTIVE=homelab`, `JAVA_TOOL_OPTIONS`(선택: 초기 `-Xmx512m -Duser.timezone=UTC` 후보), `TZ=UTC`는 비민감 ConfigMap 항목이다. 표의 DB_HOST/PORT/NAME/USERNAME과 DB_PASSWORD는 정확한 JDBC property로 연결된다. 표준 Spring 환경변수로 property 전체를 override할 수도 있지만 상충하는 입력을 중복 지정하지 않는다. + +OAuth Google client ID는 공개 식별자지만 credential 묶음으로 SOPS 관리한다. DB_USERNAME은 role 식별자여서 ConfigMap 가능하며 password는 반드시 Secret이다. POSTGRES_* 초기화 값은 Backend 변수와 별개이며 후속 DB manifest 단계에서 설계한다. Backend에 PostgreSQL bootstrap superuser를 제공하지 않는다. Redis server password 설정도 이 profile 밖에서 별도 설계한다. + +## Health / Security / 종료 + +- GET `/actuator/health`, `/actuator/health/liveness`, `/actuator/health/readiness` 세 경로만 homelab에서 익명 허용한다. `/actuator/health/db` 등 기타 하위 경로와 전체 actuator API는 deny한다. +- web 노출은 health만, details/components는 never. management endpoints default access none + health read-only다. env/configprops/heapdump를 추가 노출하더라도 security chain이 거부하는 테스트를 포함했다. +- liveness group은 **livenessState만** 포함한다. DB/Redis가 내려가도 재시작 loop를 유발하지 않는다. +- readiness는 readinessState+db+redis다. 인증/핵심 데이터 서비스가 정상일 때만 요청을 받도록 선택했다. SMTP/S3/Google/Discord는 포함하지 않는다. +- probes는 현재 8080의 실제 application HTTP server를 검사하도록 설계했다. 내부 probe 접근을 열었다고 health가 인터넷에서 절대 접근 불가한 것은 아니다. 후속 HTTPRoute는 필요한 API 경로만 노출하고 `/actuator`를 public routing하지 않아야 한다. 아직 Route는 생성하지 않았다. +- startupProbe: liveness 경로, period5s/timeout2s/failure60 후보. liveness:10s/2s/3, readiness:5s/2s/3. 실제 초기 migration 시간을 관찰해 조정한다. +- graceful shutdown 활성화, Spring phase30s. 향후 terminationGracePeriodSeconds45s 후보. 실제 SIGTERM/트래픽 drain은 배포 전 통합 검증 대상이다. +- 기존 API 인증체계는 그대로이며 new chain은 /actuator 범위만 우선 처리한다. homeLab CORS Filter는 profile 한정으로 API 경로만 처리한다. + +## CORS / OAuth / Cookie + +`CORS_ALLOWED_ORIGINS`는 실제 frontend origin을 쉼표로 나눈 명시 목록이며 wildcard/경로 포함 origin은 시작 시 거부한다. credential 요청을 허용하고 methods GET/POST/PUT/PATCH/DELETE/OPTIONS, headers Authorization/Content-Type/Accept/X-App-Subdomain만 허용한다. prod/dev에는 새 CORS filter를 등록하지 않는다. 이 변경은 기존 CSRF/authentication 정책을 재설계하지 않는다. + +prod CookieProvider를 재사용하고 cookie Secure=true, domain 기본 redot-api.dhxxn.kr, SameSite 기본 Lax로 override한다. frontend가 다른 site(redot.me 등)면 Lax로 fetch 인증이 동작하지 않을 수 있으므로 실제 origin을 받고 SameSite=None/브라우저 정책을 검증해야 한다. callback/redirect는 필수 입력이며 가짜 frontend URL을 기본값으로 넣지 않았다. + +OAuth base URL 기본값만 https://redot-api.dhxxn.kr이다. GOOGLE_REDIRECT_URI는 실제 Google console와 일치해야 한다. 현재 FlowAwareRedirectUriResolver는 frontend redirect_uri에 따라 API callback host를 바꿀 수 있다. 이를 이번에 변경하지 않았으며 frontend reverse proxy 유무에 따라 별도 인증 흐름 검증이 필요하다. Swagger는 homelab에서 api-docs/UI 모두 비활성화한다. 기존 !prod Swagger URL이나 production 도메인은 수정하지 않았다. + +forward-headers-strategy=framework를 기본으로 하므로 후속 ingress에서는 전달 header 신뢰 경계를 검증한다. Backend 직접 외부 노출을 허용하지 않는다. + +## DB / Redis / Storage / 자원 설계 + +**신규 DB 버전은 PostgreSQL 17.11로 확정했다.** 사용자 후속 지시로 과거 DB 버전 추적은 종료했다. 17 계열은 2029-11-08까지 지원되고 2026-09-27 공식 최신 minor는17.11이다. 현재 Flyway11.7.2 소스의 권장 최신 major 기준도17이어서 18을 도입하며 dependency까지 바꾸지 않는다. image는 `postgres:17.11-bookworm@sha256:91eb910c44c7ed13f7f1a4ccadaa9ca72ef14cddc04cacb6e070e48eb44731a3` (amd64)로 고정했다. K3s에는 만들지 않고 임시 Docker DB에서 검증한다. + +근거: [PostgreSQL versioning](https://www.postgresql.org/support/versioning/), [Flyway11.7.2 PostgreSQLDatabase](https://github.com/flyway/flyway/blob/flyway-11.7.2/flyway-database/flyway-database-postgresql/src/main/java/org/flywaydb/database/postgresql/PostgreSQLDatabase.java). + +Flyway enabled=true, clean-disabled=true, 기본 migration 위치, Hibernate ddl-auto=validate로 고정했다. datasource→Flyway V1~V7→JPA 초기화 순서를 사용한다. migration SQL/entity는 수정하지 않았다. 테이블 이름/컬럼의 정적 대조는 실제 PostgreSQL 실행 및 Hibernate validation을 대신하지 않는다. 실제 17.11 빈 DB에서 V1~V7 및 ddl-auto=validate 통합 테스트를 수행하며 최종 결과를 아래 기록한다. 기존 데이터 이관은 history/checksum/baseline과 사전 백업을 별도 검토한다. + +Redis는 인증 코드/검증 token/cooldown을 저장한다. 재시작 후 AOF가 정상 복구되고 TTL이 남아 있으면 이어갈 수 있지만 유실되면 인증 재요청이 필요하고 cooldown도 초기화된다. 접근 실패는 해당 인증 기능 실패로 이어진다. AOF1Gi 전용 PVC/StatefulSet1개/N100, exact Redis image pin은 배포 단계에서 검증한다. 단순 disposable cache로 취급하지 않는다. + +PostgreSQL은 N100/StatefulSet1개/PVC10Gi, StorageClass n100-local-retain. 두 PVC는 별개이며 Retain/Prune 보호가 필요하다. 요청량은 quota가 아니며 같은 N100 300GiB filesystem을 공유한다. 아직 어떤 PVC도 생성하지 않았다. + +Backend 첫 배포 N100/1replica, requests250m CPU/512Mi RAM, memory limit1Gi. CPU limit은 초기 미설정 권장(시작/GC throttling을 피하고 metrics로 관찰). heap512Mi 후보로 native/metaspace/thread 여유를 둔다. 실제 resource reservation/peak와 Grafana OOM/restart/CPU 추세를 확인해 조정한다. 향후 Pi 허용은 새 arm64 image 검증이 필요하며 이번 CI에 multi-arch를 추가하지 않았다. + +## 검증 결과와 남은 gate + +실제 실행 결과는 아래 최종 검증 절을 따른다. 새 health 테스트는 dependency 장애를 stub으로 재현하며 실제 PostgreSQL/Redis durability나 OAuth를 검증한 것은 아니다. 기존 contextLoads를 제거/skip하거나 -x test로 실패를 숨기지 않는다. + +사용자 확인: 아직 미배포인 frontend의 향후 origin/로그인 성공·실패 redirect/허용 host, Google callback 등록, 후속 runtime credential 안전 입력, 초기 관리자/기준 데이터, 외부 백업. DB는 신규 생성으로 확정됐으며 과거 버전이나 이관 여부를 다시 묻지 않는다. Secret 값은 채팅에 보내지 않고 후속 SOPS 입력 단계에서만 제공한다. + +commit/push 전에 변경 파일/test/Docker 결과를 사용자에게 보고하고 멈춘다. 현 workflow는 자동 EC2/old GitOps 배포를 유발할 수 있으므로 무조건 push하지 않는다. rollback은 이번 로컬 변경을 되돌리는 것으로 충분하다. Kubernetes 변경은 없다. + +## PostgreSQL/Redis가 없는 테스트와 integration test 구분 + +- `./gradlew test`: 외부 PostgreSQL/Redis 없이 health indicator 장애, Security 접근 범위, CORS, runtime placeholder를 검사한다. +- `./gradlew integrationTest`: 전체 애플리케이션 context를 실제 임시 PostgreSQL/Redis에 연결한다. 기존 contextLoads를 이 태그로 분류하고 실제 fixture를 제공했다. 테스트를 삭제/무조건 skip한 것이 아니다. +- `python3 scripts/test-kubernetes.py`: 고정된 Docker image로 비공개 테스트 network, PostgreSQL tmpfs, 임시 Redis AOF 디렉터리를 만들고 위 두 task 및 kubernetesBootJar를 실행한다. host port 공개 없음. password는 매번 메모리에서 임의 생성해 runtime env/protected 설정 파일에만 전달한다. 외부 SMTP/OAuth/S3 호출은 하지 않는다. 종료 시 자기 테스트 container/network/data만 정리한다. 실행자에게 Docker 권한이 필요하며 host Java21은 없어도 된다. +- 실제 production credential을 넣지 않는다. 이 script는 DB/Flyway/schema 및 local runtime 검증이지 Google 계정 로그인이나 실제 email/S3 동작 검증은 아니다. +- 전체 검증이 통과하기 전 CI에 test 성공을 가정한 자동 push를 연결하지 않는다. 기존 CI가 -x test인 이유는 repository 이력에 명시돼 있지 않다. baseline에서 실제 DB 설정 부재 실패가 관측됐지만 이것이 과거 작성자의 의도였다고 단정하지 않는다. + +## Google callback 검증 기준 + +registrationId는 `redot-member-google`, flow는 `redot-member`이다. 후속 Google Cloud Console의 Authorized redirect URIs에 추가할 값은: + +`https://redot-api.dhxxn.kr/api/v1/sign-in/redot-member/social/callback/redot-member-google` + +홈랩 `GOOGLE_REDIRECT_URI`에도 같은 값을 넣는다. 이는 frontend URL이 아니라 Backend OAuth callback이다. Security social chain matcher와 OAuth2LoginAuthenticationFilter가 이 경로를 처리하며 resolver 검증 테스트를 추가했다. Google credential 값은 지금 요청하지 않는다. + +**기존 동작 한계:** 로그인 요청에 허용된 frontend `redirect_uri`를 보내면 FlowAwareRedirectUriResolver가 callback host도 그 frontend로 바꾼다. 이번에는 인증 구조 변경 금지에 따라 수정하지 않았다. Backend callback을 유지하려면 우선 override 없는 흐름을 검증하고, frontend 확정 후 frontend proxy 또는 승인된 resolver 수정 여부를 결정해야 한다. 성공/실패 redirect와 CORS는 runtime 변수이며 redot.dhxxn.kr 등을 실제 origin으로 확정하지 않았다. + +## 최종 검증 — 2026-09-27 UTC + +- 외부 DB 없는 test: 12개 성공. HealthProbe7 / CORS3 / runtime profile1 / legacy health opt-in1. +- 실제 PostgreSQL17.11 + Redis7.4.11 integrationTest: 5개 성공. 원래 contextLoads 유지, Flyway V1~V7 이력 확인과 Hibernate validate 완료, Redis TTL 저장, Google callback/security chain 일치, 기존 frontend override 동작, 실제 context probe 및 member API 비인증 차단 검증. +- Redis AOF 테스트 전용 비민감 marker를 쓰고 해당 테스트 container만 재시작하여 보존을 확인했다. 테스트 container/network/data는 모두 제거했다. K3s 리소스 생성 없음. +- 실제 JAR dependency: PostgreSQL JDBC42.7.7 / Flyway11.7.2 / Hibernate6.6.22.Final. SQL/entity 수정 없음. +- 합성 표식을 application-prod.yml/application-secret.yml/.env/secrets.txt에 넣은 packaging 음성 테스트 통과. JAR에는 공개 profile2개와 migration7개만 있고 표식은 없었다. 시험 파일은 제거했다. 실제 credential을 검사에 사용하지 않았다. +- Docker build 성공: local tag redot-server:k8s-preparation, linux/amd64, USER10001:10001, Java21.0.12.1, image size194143982bytes. 숫자 UID/GID를 사용하며 passwd의 별도 이름은 없다. `id -u/-g` 각각10001, read-only rootfs+tmpfs/tmp에서 Java 실행 검증. +- image 안 JAR와 검증한 artifact checksum 일치. 같은 고정 JDK/소스로 task를 재실행한 JAR SHA256도 일치했다. 새로운 source/dependency 버전까지 동일하다는 의미는 아니다. +- Dockerfile/Compose/기존 두 workflow/기존 SecurityConfig는 원본 byte 일치. 새 Actuator dependency와 public base settings가 추가되므로 실제 EC2 재배포 무영향까지 보장하지 않으며 현재 EC2에는 아무 변경도 하지 않았다. +- 단일 local image를 build했을 뿐 registry push/전체 image의 운영 credential 기반 기동/실제 Google 로그인·SMTP·S3 호출은 하지 않았다. + +### BUILD FAILED 구분 + +| 단계 | 원인 | 처리/최종 상태 | +|---|---|---| +| 변경 전 main의 원래 test | profile/datasource URL 부재로 contextLoads 초기화 실패 | baseline failure. DB driver가 dependency에 없다는 뜻이 아님 | +| 새 테스트 첫 compile | CORS factory가 package-private여서 다른 package 테스트가 호출 못함 | 제가 작성한 오류. public으로 수정, compile 성공 | +| 새 health 테스트 초기 context | TestConfiguration이 main scan을 보완해 원치 않는 application bean을 로드 | 제가 작성한 fixture 오류. 별도 package/config로 격리하여 성공 | +| custom kubernetesBootJar | targetJavaVersion이 자동 채워지지 않음 | 제가 작성한 task 오류. Java21 명시 후 package 성공 | +| fixture 보완 전 전체 test | 기존 contextLoads의 datasource 실패 반복 | 원래 테스트를 integration으로 분류하고 임시 의존성 제공. 최종5개 통합 테스트 모두 성공 | + +실패를 숨기는 -x test는 사용하지 않았다. baseline/개발 중 오류와 최종 결과는 구분한다. 민감할 수 있는 raw log 대신 docs/validation-results.json의 상태/파일 경로/버전/비민감 hash만 review 자료로 사용한다. + +홈랩 전용 이미지 발행 준비: [HOMELAB-IMAGE-PUBLISHING.md](HOMELAB-IMAGE-PUBLISHING.md). 아직 commit/push 및 배포하지 않았습니다. diff --git a/docs/homelab-integration-results.md b/docs/homelab-integration-results.md new file mode 100644 index 0000000..e056c52 --- /dev/null +++ b/docs/homelab-integration-results.md @@ -0,0 +1,41 @@ +# 새 홈서버 런타임 통합 검증 · 2026-10-14 + +## 통합 이유와 변경 + +기존 develop의 문의번호 수정 PR #140은 새 홈서버가 실행하는 prepare/kubernetes-runtime 코드와 분리돼 있었다. develop의 오래된 자동 배포도 별도 gitops-repo를 갱신하고 있었다. + +- 통합 부모: develop `f80f035cf459aadded71bc4821539e32a5193867`, runtime `ce08b1eec394ef4c5bdad8350ad30b28647f39b2`. +- 문의번호 날짜 초기화·비관적 락·트랜잭션 코드는 develop 그대로 보존했다. 변경한 운영 코드는 기존 runtime 브랜치의 설정·health·CORS다. +- build.gradle 충돌은 test/inquiryExperiment와 integrationTest/kubernetesBootJar를 함께 유지해 해결했다. 동일 이름의 컨텍스트 테스트 충돌은 두 클래스를 보존해 해결했다. +- 기본 test는 문의 12 + 기존 컨텍스트 1 + 홈랩 단위 12를 포함한다. Docker 내부에는 Docker 소켓을 전달하지 않고 homelabUnitTest 12 + integrationTest 5를 실행한다. 중복 단위 12를 제외하면 서로 다른 검증은 30개다. +- PostgreSQL 17.11, Redis 7.4.11 및 Java 21 이미지는 runtime-images.json의 linux/amd64 digest를 사용한다. 로컬 호스트는 macOS arm64이므로 Docker amd64 에뮬레이션이다. 운영 N100 성능 측정이 아니다. +- 기존 test 서버 자동 배포 workflow 제거. 새 GHCR 발행은 develop workflow_dispatch만 허용한다. 기본 브랜치 develop을 GitHub API로 확인했다. main EC2 workflow는 유지한다. +- 기존 성능 실험의 임시 사본에도 runtime-images.json을 포함하고 새 manifest에 이미지 정보를 기록한다. 기존 PG16 측정 데이터는 보존한다. + +## 실행 기록 + +- `./gradlew clean build`: 25개 통과, 실패·오류·스킵 0. 문의 동시성 12개는 PostgreSQL 17.11 digest에서 실행했다. +- `python3 scripts/test-kubernetes.py`: 종료 코드 0. homelabUnitTest 12개와 integrationTest 5개 통과, 실패·오류·스킵 0. Redis AOF 재시작 후 값 유지 PASS. Flyway V1~V7, Hibernate validate, Redis TTL, OAuth callback·보호된 API·health 검증 포함. +- `python3 scripts/check-kubernetes-jar.py`: 공개 리소스 허용목록 및 원본 일치 PASS. +- `docker build --platform linux/amd64 -f Dockerfile.kubernetes`: 로컬 빌드 PASS. 이미지 linux/amd64, USER 10001:10001, Java 21.0.12.1 실행 확인. 레지스트리 게시 안 함. 이 단계는 컨테이너의 전체 서비스 기동 시험을 대신하지 않는다. +- 임시 redot-it 테스트 컨테이너가 남지 않은 것을 확인했다. +- 워크플로 YAML 구문 검사와 git diff --check 통과. +- 요약: [실측 검증 JSON](homelab-integration-validation.json). native 25와 container 17은 12개 단위 테스트가 중복이므로 총 42개의 서로 다른 테스트로 표현하지 않는다. +- 원시 Spring 통합 보고서는 임시 fixture 연결 정보를 포함할 수 있어 Git에 넣지 않는다. 로컬 build/test-results, build/kubernetes-validation.log에 남긴다. +- amd64 Docker 실행에서 Gradle 파일 감시 경고가 있었지만 전체 테스트·패키징 종료 코드는 0이었다. 테스트의 OAuth2ClientAutoConfiguration deprecated 경고도 남는다. 기능 실패로 판정되지는 않았다. + +## 발견·수정 및 리뷰 + +- 첫 native 실행: Testcontainers가 tag@digest 표기를 postgres 이미지로 인식하지 못하여 컨테이너 초기화 실패 2개. 같은 digest의 repository@digest로 변환한 뒤 전체 빌드를 재실행했다. digest나 버전을 낮추지 않았다. +- 기존 실험 스크립트의 docs 일괄 제외가 새 이미지 설정 파일을 누락시켜 보완했다. 실제 main을 실행하고 Gradle subprocess만 mock한 사본 준비 검증으로 3개 대조군 모두 해당 파일과 runtime provenance 존재를 확인했다. 이 smoke 검증은 부하 실험이 아니다. +- 독립 읽기 전용 리뷰에서 중요 추가 지적 없음. 리뷰 담당자는 테스트를 재실행하지 않았으며 실행 결과 확인은 작업 담당자가 수행했다. + +## 한계와 다음 배포 단계 + +이번 작업은 이슈·PR까지만 진행한다. 새 이미지 게시, homelab manifest digest 갱신, Argo CD Sync, 운영 문의 생성/조회는 수행하지 않는다. 실제 Google 로그인·SMTP·S3 외부 호출도 검증하지 않는다. + +과거 PG16 72,000건 실험 수치를 PG17이나 운영 성능으로 옮겨 쓰지 않는다. 이번에는 정합성·런타임 호환성 회귀 검증이며 성능 개선율을 새로 주장하지 않는다. + +PR 병합 후 develop 수동 GHCR 발행 → registry digest 확인 → homelab manifest 변경 PR → 승인 후 수동 Sync → readiness와 문의 생성/조회 확인이 남는다. + +추적 이슈: [#141](https://github.com/redotlabs/server/issues/141). diff --git a/docs/homelab-integration-validation.json b/docs/homelab-integration-validation.json new file mode 100644 index 0000000..29c0fbb --- /dev/null +++ b/docs/homelab-integration-validation.json @@ -0,0 +1,130 @@ +{ + "date": "2026-10-14", + "host": "macOS arm64; Docker linux/amd64 emulation", + "runtime_images": { + "postgresql": { + "version": "17.11", + "image": "postgres:17.11-bookworm@sha256:91eb910c44c7ed13f7f1a4ccadaa9ca72ef14cddc04cacb6e070e48eb44731a3", + "platform": "linux/amd64" + }, + "redis": { + "version": "7.4.11", + "image": "redis:7.4.11-alpine@sha256:ca0acbb137c1dc3339c8b147a58fd6f42775d4599327b50e7b116c23de501af2", + "platform": "linux/amd64" + }, + "test_jdk": { + "version": "21", + "image": "eclipse-temurin:21-jdk@sha256:47ba03cf9270176dd8da310fc89fe940905da6618dc5de19fc6a723b8872b3ad", + "platform": "linux/amd64" + }, + "runtime_jre": { + "version": "21", + "image": "eclipse-temurin:21-jre@sha256:628467024d428251da635661936d6180717b1ac1119d41c76d28a03b7293c43c", + "platform": "linux/amd64" + } + }, + "tests": [ + { + "task": "test", + "class": "homelabtests.HealthProbeTests", + "tests": 7, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "task": "test", + "class": "homelabtests.HomelabCorsTests", + "tests": 3, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "task": "test", + "class": "homelabtests.HomelabProfileTests", + "tests": 1, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "task": "test", + "class": "homelabtests.LegacyHealthSecurityTests", + "tests": 1, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "task": "test", + "class": "redot.redot_server.RedotServerApplicationTests", + "tests": 1, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "task": "test", + "class": "redot.redot_server.domain.cms.inquiry.service.InquiryConcurrencyTest", + "tests": 12, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "task": "homelabUnitTest", + "class": "homelabtests.HealthProbeTests", + "tests": 7, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "task": "homelabUnitTest", + "class": "homelabtests.HomelabCorsTests", + "tests": 3, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "task": "homelabUnitTest", + "class": "homelabtests.HomelabProfileTests", + "tests": 1, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "task": "homelabUnitTest", + "class": "homelabtests.LegacyHealthSecurityTests", + "tests": 1, + "failures": 0, + "errors": 0, + "skipped": 0 + }, + { + "task": "integrationTest", + "class": "redot.redot_server.HomelabApplicationTests", + "tests": 5, + "failures": 0, + "errors": 0, + "skipped": 0 + } + ], + "unique_test_count": 30, + "redis_aof_restart": "PASS", + "jar_public_resource_allowlist": "PASS", + "jar_sha256": "9271dd7b3f1978566564c43a8b822d8710dab9386ea6b2fa4b946f2efd1dcc42", + "production_deployed": false, + "load_experiment_rerun": false, + "local_image": { + "build": "PASS", + "platform": "linux/amd64", + "user": "10001:10001", + "java_version": "21.0.12.1", + "pushed": false + }, + "owned_test_containers_remaining": [] +} diff --git a/docs/inquiry-concurrency.md b/docs/inquiry-concurrency.md index 951156b..d0250d4 100644 --- a/docs/inquiry-concurrency.md +++ b/docs/inquiry-concurrency.md @@ -1,3 +1,5 @@ +> 아래 수치는 PostgreSQL 16에서 실행한 당시 기록이다. 2026-10-14 홈랩 통합부터 테스트 이미지는 docs/runtime-images.json의 PostgreSQL 17.11로 고정한다. 새 검증은 [통합 결과](homelab-integration-results.md)를 참조한다. + # 문의번호 첫 날짜 동시성 개선 검증일: 2026-10-14. 기준 커밋: `c373b2053b7f15c684ee8ffae2ba1665e63acf2d`. diff --git a/docs/runtime-images.json b/docs/runtime-images.json new file mode 100644 index 0000000..9fdc3b7 --- /dev/null +++ b/docs/runtime-images.json @@ -0,0 +1,22 @@ +{ + "postgresql": { + "version": "17.11", + "image": "postgres:17.11-bookworm@sha256:91eb910c44c7ed13f7f1a4ccadaa9ca72ef14cddc04cacb6e070e48eb44731a3", + "platform": "linux/amd64" + }, + "redis": { + "version": "7.4.11", + "image": "redis:7.4.11-alpine@sha256:ca0acbb137c1dc3339c8b147a58fd6f42775d4599327b50e7b116c23de501af2", + "platform": "linux/amd64" + }, + "test_jdk": { + "version": "21", + "image": "eclipse-temurin:21-jdk@sha256:47ba03cf9270176dd8da310fc89fe940905da6618dc5de19fc6a723b8872b3ad", + "platform": "linux/amd64" + }, + "runtime_jre": { + "version": "21", + "image": "eclipse-temurin:21-jre@sha256:628467024d428251da635661936d6180717b1ac1119d41c76d28a03b7293c43c", + "platform": "linux/amd64" + } +} diff --git a/docs/superpowers/plans/2026-10-14-homelab-integration.md b/docs/superpowers/plans/2026-10-14-homelab-integration.md new file mode 100644 index 0000000..60fcb8b --- /dev/null +++ b/docs/superpowers/plans/2026-10-14-homelab-integration.md @@ -0,0 +1,11 @@ +# 새 홈서버 통합 실행 계획 + +사용자 승인 범위: develop 문의번호 수정과 prepare/kubernetes-runtime 통합, 격리 테스트, 이슈·커밋·PR. 이미지 게시·실제 배포·PR 병합은 후속 작업. + +1. 두 브랜치의 테스트를 별도 클래스로 보존하고 Gradle 검증 작업을 통합한다. +2. runtime-images.json의 PostgreSQL 17.11 amd64로 문의번호 회귀 테스트를 실행한다. Docker 내부 테스트는 Docker 소켓 없이 실행하고 Testcontainers 테스트는 호스트에서 별도로 실행한다. +3. 오래된 develop→Docker Hub→gitops-repo 테스트 배포를 제거한다. GHCR 게시를 develop 수동 실행으로 제한하고 게시 전 양쪽 검증을 실행한다. +4. Redis 7.4.11 AOF 재시작, Flyway/Hibernate, OAuth·CORS·health 노출, 패키징 허용목록을 검증한다. +5. 결과와 제한을 기록하고 전체 변경 리뷰 후 이슈·커밋·PR을 생성한다. + +기존 PG16 성능 측정 자료는 당시 조건의 이력으로 보존한다. 이번 PG17 검증 결과를 해당 수치의 재측정으로 표현하지 않는다. diff --git a/docs/validation-results.json b/docs/validation-results.json new file mode 100644 index 0000000..1fad28a --- /dev/null +++ b/docs/validation-results.json @@ -0,0 +1,137 @@ +{ + "historical_snapshot": "2026-09-27 runtime preparation; current integration results: docs/homelab-integration-results.md", + "tests": [ + { + "task": "integrationTest", + "class": "redot.redot_server.RedotServerApplicationTests", + "tests": 5, + "failures": 0, + "errors": 0 + }, + { + "task": "test", + "class": "homelabtests.HealthProbeTests", + "tests": 7, + "failures": 0, + "errors": 0 + }, + { + "task": "test", + "class": "homelabtests.HomelabCorsTests", + "tests": 3, + "failures": 0, + "errors": 0 + }, + { + "task": "test", + "class": "homelabtests.HomelabProfileTests", + "tests": 1, + "failures": 0, + "errors": 0 + }, + { + "task": "test", + "class": "homelabtests.LegacyHealthSecurityTests", + "tests": 1, + "failures": 0, + "errors": 0 + } + ], + "jar_sha256": "9e60b4469739689249ff31277a19ccf07ea27b875601fe19cf441fca83e66051", + "packaged_dependencies": [ + "BOOT-INF/lib/flyway-core-11.7.2.jar", + "BOOT-INF/lib/flyway-database-postgresql-11.7.2.jar", + "BOOT-INF/lib/hibernate-core-6.6.22.Final.jar", + "BOOT-INF/lib/postgresql-42.7.7.jar" + ], + "packaged_resources": [ + "BOOT-INF/classes/application-homelab.yml", + "BOOT-INF/classes/application.yml", + "BOOT-INF/classes/db/migration/V1__initial_schema.sql", + "BOOT-INF/classes/db/migration/V2__change_redot_app_status_to_string.sql", + "BOOT-INF/classes/db/migration/V3__create_site_pages.sql", + "BOOT-INF/classes/db/migration/V4__add_consultation_remark_and_cancelled_status.sql", + "BOOT-INF/classes/db/migration/V5__update_redot_member_status_check.sql", + "BOOT-INF/classes/db/migration/V6__add_redot_app_remark.sql", + "BOOT-INF/classes/db/migration/V7__add_index_to_redot_members_created_at.sql" + ], + "legacy_files_unchanged": { + "Dockerfile": true, + "docker-compose.prod.yml": true, + ".github/workflows/deploy-to-prod-server.yml": true, + ".github/workflows/deploy-to-test-server.yml": true, + "src/main/java/redot/redot_server/global/security/config/SecurityConfig.java": true + }, + "redis_aof_restart_persistence": true, + "postgresql_version": "17.11", + "flyway_applied_versions": [ + "1", + "2", + "3", + "4", + "5", + "6", + "7" + ], + "hibernate_validate_passed": true, + "kubernetes_changes": false, + "commit_push": false, + "image_jar_matches_verified_jar": true, + "docker_image": { + "tag": "redot-server:k8s-preparation", + "id": "sha256:8727664d3616e603c9f98cf20112bd67bfb8b89e0f1cd910bb77bdedc76c11d8", + "platform": "linux/amd64", + "user": "10001:10001", + "size_bytes": 194143982, + "entrypoint": [ + "java", + "-jar", + "/app/app.jar" + ], + "java_version": "21.0.12.1" + }, + "packaging_canary_check": { + "synthetic_injected_configuration_excluded": true, + "resource_paths": [ + "BOOT-INF/classes/application-homelab.yml", + "BOOT-INF/classes/application.yml", + "BOOT-INF/classes/db/migration/V1__initial_schema.sql", + "BOOT-INF/classes/db/migration/V2__change_redot_app_status_to_string.sql", + "BOOT-INF/classes/db/migration/V3__create_site_pages.sql", + "BOOT-INF/classes/db/migration/V4__add_consultation_remark_and_cancelled_status.sql", + "BOOT-INF/classes/db/migration/V5__update_redot_member_status_check.sql", + "BOOT-INF/classes/db/migration/V6__add_redot_app_remark.sql", + "BOOT-INF/classes/db/migration/V7__add_index_to_redot_members_created_at.sql" + ] + }, + "repeat_build_jar_sha256_matches": true, + "temporary_test_containers_remaining": [], + "source_base_commit": "c8fa281f40c3cb02b107b9e30a10e84f69b1df90", + "worktree": "/home/dhxxn/redot-server-k8s", + "branch": "prepare/kubernetes-runtime", + "google_callback": "https://redot-api.dhxxn.kr/api/v1/sign-in/redot-member/social/callback/redot-member-google", + "google_callback_without_frontend_override_verified": true, + "existing_frontend_override_behavior_unchanged": true, + "baseline_failure": { + "test": "RedotServerApplicationTests.contextLoads", + "cause": "Missing datasource/profile configuration; no suitable driver could be determined without JDBC URL", + "observed_before_changes": true, + "resolution": "Explicit isolated fixtures and integrationTest task; test retained and passes" + }, + "development_failures_resolved": [ + { + "issue": "CORS test could not access package-private bean factory", + "resolution": "Made bean factory public" + }, + { + "issue": "Probe TestConfiguration supplemented main component scan and failed on repository dependencies", + "resolution": "Isolated test configuration outside application package" + }, + { + "issue": "Custom BootJar targetJavaVersion unset", + "resolution": "Explicit JavaVersion.VERSION_21" + } + ], + "postgresql_image": "postgres:17.11-bookworm@sha256:91eb910c44c7ed13f7f1a4ccadaa9ca72ef14cddc04cacb6e070e48eb44731a3", + "migration_files_unchanged": true +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da..d8ca3f0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -5,3 +5,5 @@ networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists + +distributionSha256Sum=bd71102213493060956ec229d946beee57158dbd89d0e62b91bca0fa2c5f3531 diff --git a/scripts/check-kubernetes-jar.py b/scripts/check-kubernetes-jar.py new file mode 100644 index 0000000..a412ad7 --- /dev/null +++ b/scripts/check-kubernetes-jar.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python3 +"""Check packaging boundaries; never print configuration contents.""" +from pathlib import Path +import zipfile +root = Path(__file__).resolve().parents[1] +expected = {"application.yml", "application-homelab.yml"} +expected |= {str(p.relative_to(root / "src/main/resources")) for p in (root / "src/main/resources/db/migration").glob("*.sql")} +with zipfile.ZipFile(root / "build/kubernetes/app.jar") as jar: + actual = {n.removeprefix("BOOT-INF/classes/") for n in jar.namelist() + if n.startswith("BOOT-INF/classes/") and not n.endswith(("/", ".class"))} + assert actual == expected, "JAR resource allowlist mismatch" + for name in expected: + assert jar.read("BOOT-INF/classes/" + name) == (root / "src/main/resources" / name).read_bytes(), "Packaged resource differs from public source" +print("JAR public resource allowlist and source match: PASS") diff --git a/scripts/inquiry-experiment.py b/scripts/inquiry-experiment.py index f30a89a..aed865a 100644 --- a/scripts/inquiry-experiment.py +++ b/scripts/inquiry-experiment.py @@ -32,6 +32,7 @@ def main(): 'private final InquirySequenceRepository inquirySequenceRepository;\n private final Clock inquiryClock;') original = original.replace('LocalDate.now()', 'LocalDate.now(inquiryClock)') manifest = {'base': BASE, 'source_head': subprocess.check_output(['git','rev-parse','HEAD'],cwd=root).decode().strip(), + 'runtime_images': json.loads((root / 'docs/runtime-images.json').read_text()), 'clock_adaptation': 'original/unlocked only LocalDate.now -> injected test Clock', 'variants': {}} for variant in ['original', 'unlocked', 'fixed']: destination = output / variant @@ -39,7 +40,7 @@ def main(): with tempfile.TemporaryDirectory(prefix=f'redot-{variant}-') as tmp: checkout = Path(tmp) for name in files: - if not name or name.startswith(('docs/', 'artifacts/')): + if not name or (name.startswith(('docs/', 'artifacts/')) and name != 'docs/runtime-images.json'): continue source = root / name if source.is_file(): diff --git a/scripts/test-kubernetes.py b/scripts/test-kubernetes.py new file mode 100755 index 0000000..4e4aa88 --- /dev/null +++ b/scripts/test-kubernetes.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Local tests only: no host ports, production credentials, or Kubernetes access.""" +import json +import os +from pathlib import Path +import secrets +import subprocess +import tempfile +import time + +ROOT = Path(__file__).resolve().parents[1] +IMAGES = json.loads((ROOT / "docs/runtime-images.json").read_text()) +RUN = "redot-it-" + secrets.token_hex(4) +NETWORK = RUN +DB = RUN + "-db" +REDIS = RUN + "-redis" +RUNNER = RUN + "-runner" +DB_PASSWORD = secrets.token_urlsafe(32) +REDIS_PASSWORD = secrets.token_urlsafe(32) +ENV = dict(os.environ, POSTGRES_PASSWORD=DB_PASSWORD, + REDOT_IT_DB_HOST=DB, REDOT_IT_DB_PASSWORD=DB_PASSWORD, + REDOT_IT_REDIS_HOST=REDIS, REDOT_IT_REDIS_PASSWORD=REDIS_PASSWORD, REDISCLI_AUTH=REDIS_PASSWORD) +created = [] +network_created = False + +def execute(args, *, check=True, timeout=600): + result = subprocess.run(args, cwd=ROOT, env=ENV, capture_output=True, text=True, timeout=timeout) + if check and result.returncode: + # Never echo command/env or raw process output containing credentials. + raise RuntimeError("Test command failed: " + args[0] + " (exit " + str(result.returncode) + ")") + return result + +temporary = tempfile.TemporaryDirectory(prefix="redot-it-") +try: + tmp = Path(temporary.name) + config = tmp / "redis.conf" + config.write_text("bind 0.0.0.0\nprotected-mode yes\nport 6379\nappendonly yes\nappendfsync everysec\ndir /data\nrequirepass " + REDIS_PASSWORD + "\n") + config.chmod(0o600) + data = tmp / "data" + data.mkdir(mode=0o700) + execute(["docker", "network", "create", NETWORK]) + network_created = True + execute(["docker", "run", "-d", "--platform", IMAGES["postgresql"]["platform"], "--name", DB, "--network", NETWORK, + "--memory", "512m", "--cpus", "1", "--tmpfs", "/var/lib/postgresql/data:rw,size=268435456", + "-e", "POSTGRES_DB=redot_test", "-e", "POSTGRES_USER=redot_test", "-e", "POSTGRES_PASSWORD", + "-e", "POSTGRES_INITDB_ARGS=--encoding=UTF8", "-e", "TZ=UTC", IMAGES["postgresql"]["image"]]) + created.append(DB) + execute(["docker", "run", "-d", "--platform", IMAGES["redis"]["platform"], "--name", REDIS, "--network", NETWORK, + "--memory", "256m", "--cpus", "0.5", "--user", f"{os.getuid()}:{os.getgid()}", + "-v", f"{config}:/usr/local/etc/redis/redis.conf:ro", "-v", f"{data}:/data", + IMAGES["redis"]["image"], "redis-server", "/usr/local/etc/redis/redis.conf"]) + created.append(REDIS) + for _ in range(60): + if execute(["docker", "exec", DB, "pg_isready", "-U", "redot_test", "-d", "redot_test"], check=False).returncode == 0: + break + time.sleep(1) + else: + raise RuntimeError("Isolated PostgreSQL did not become ready") + redis_cli = ["docker", "exec", "-e", "REDISCLI_AUTH", REDIS, "redis-cli", "--raw"] + for _ in range(60): + if execute(redis_cli + ["PING"], check=False).stdout.strip() == "PONG": + break + time.sleep(1) + else: + raise RuntimeError("Isolated Redis did not become ready") + execute(redis_cli + ["SET", "redot-aof-probe", "persistence-ok", "EX", "120"]) + time.sleep(2) + execute(["docker", "restart", REDIS]) + for _ in range(60): + if execute(redis_cli + ["PING"], check=False).stdout.strip() == "PONG": + break + time.sleep(1) + if execute(redis_cli + ["GET", "redot-aof-probe"]).stdout.strip() != "persistence-ok": + raise RuntimeError("Redis AOF restart persistence failed") + execute(redis_cli + ["DEL", "redot-aof-probe"]) + print("Redis AOF restart persistence: PASS") + cache = Path(os.environ.get("REDOT_GRADLE_CACHE", str(Path.home() / ".cache/redot-gradle"))).resolve() + cache.mkdir(parents=True, exist_ok=True) + command = ["docker", "run", "--rm", "--platform", IMAGES["test_jdk"]["platform"], "--name", RUNNER, "--network", NETWORK, + "--cpus", "2", "--memory", "2g", "--user", f"{os.getuid()}:{os.getgid()}", + "-e", "GRADLE_USER_HOME=/gradle", "-v", f"{cache}:/gradle", "-v", f"{ROOT}:/work", "-w", "/work"] + for key in ["REDOT_IT_DB_HOST", "REDOT_IT_DB_PASSWORD", "REDOT_IT_REDIS_HOST", "REDOT_IT_REDIS_PASSWORD"]: + command.extend(["-e", key]) + command.extend([IMAGES["test_jdk"]["image"], "./gradlew", "--no-daemon", "--max-workers=2", "--continue", "--rerun-tasks", + "homelabUnitTest", "integrationTest", "kubernetesBootJar"]) + result = execute(command, check=False, timeout=900) + log = result.stdout + result.stderr + for value in [DB_PASSWORD, REDIS_PASSWORD]: + log = log.replace(value, "[REDACTED]") + # Build reports are local ignored artifacts, never publish them automatically. + logpath = ROOT / "build/kubernetes-validation.log" + logpath.parent.mkdir(exist_ok=True) + fd = os.open(logpath, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + f.write(log) + print("Local Gradle tests/package exit:", result.returncode) + print("Reports: build/test-results/homelabUnitTest and build/test-results/integrationTest") + if result.returncode: + raise SystemExit(result.returncode) +finally: + execute(["docker", "rm", "-f", RUNNER], check=False) + for name in reversed(created): + execute(["docker", "rm", "-f", name], check=False) + if network_created: + execute(["docker", "network", "rm", NETWORK], check=False) + + temporary.cleanup() diff --git a/src/main/java/redot/redot_server/global/security/config/ActuatorSecurityConfig.java b/src/main/java/redot/redot_server/global/security/config/ActuatorSecurityConfig.java new file mode 100644 index 0000000..abf7a1d --- /dev/null +++ b/src/main/java/redot/redot_server/global/security/config/ActuatorSecurityConfig.java @@ -0,0 +1,27 @@ +package redot.redot_server.global.security.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration(proxyBeanMethods = false) +public class ActuatorSecurityConfig { + @Bean + @Order(-100) + SecurityFilterChain actuatorSecurityChain(HttpSecurity http, + @Value("${redot.health.public-enabled:false}") boolean publicHealth) throws Exception { + http.securityMatcher("/actuator", "/actuator/**"); + http.authorizeHttpRequests(auth -> { + if (publicHealth) { + auth.requestMatchers(HttpMethod.GET, "/actuator/health", + "/actuator/health/liveness", "/actuator/health/readiness").permitAll(); + } + auth.anyRequest().denyAll(); + }); + return http.build(); + } +} diff --git a/src/main/java/redot/redot_server/global/security/config/HomelabCorsConfig.java b/src/main/java/redot/redot_server/global/security/config/HomelabCorsConfig.java new file mode 100644 index 0000000..5dc43f6 --- /dev/null +++ b/src/main/java/redot/redot_server/global/security/config/HomelabCorsConfig.java @@ -0,0 +1,45 @@ +package redot.redot_server.global.security.config; + +import java.net.URI; +import java.util.Arrays; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.Ordered; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import org.springframework.web.filter.CorsFilter; + +@Configuration(proxyBeanMethods = false) +@Profile("homelab") +public class HomelabCorsConfig { + @Bean + public FilterRegistrationBean homelabCorsFilter( + @Value("${redot.cors.allowed-origins}") String configuredOrigins) { + List origins = Arrays.stream(configuredOrigins.split(",")) + .map(String::trim).toList(); + for (String origin : origins) { + URI uri = URI.create(origin); + if (uri.getHost() == null || origin.contains("*") || uri.getUserInfo() != null + || uri.getQuery() != null || uri.getFragment() != null + || !("https".equals(uri.getScheme()) || "http".equals(uri.getScheme())) + || (uri.getPath() != null && !uri.getPath().isEmpty())) { + throw new IllegalArgumentException("Configure explicit CORS origins without paths or wildcards"); + } + } + CorsConfiguration cors = new CorsConfiguration(); + cors.setAllowedOrigins(origins); + cors.setAllowCredentials(true); + cors.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + cors.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept", "X-App-Subdomain")); + cors.setMaxAge(600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/api/**", cors); + FilterRegistrationBean registration = new FilterRegistrationBean<>(new CorsFilter(source)); + registration.setOrder(Ordered.HIGHEST_PRECEDENCE); + return registration; + } +} diff --git a/src/main/resources/application-homelab.yml b/src/main/resources/application-homelab.yml new file mode 100644 index 0000000..ec9891b --- /dev/null +++ b/src/main/resources/application-homelab.yml @@ -0,0 +1,137 @@ +# Public runtime contract: all credentials must be injected at runtime. +# Activate homelab (which groups prod) instead of changing the existing prod profile. +server: + port: ${SERVER_PORT:8080} + shutdown: graceful + forward-headers-strategy: ${FORWARD_HEADERS_STRATEGY:framework} +spring: + lifecycle: + timeout-per-shutdown-phase: 30s + datasource: + url: jdbc:postgresql://${DB_HOST}:${DB_PORT:5432}/${DB_NAME} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + hikari: + maximum-pool-size: ${DB_POOL_SIZE:5} + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + jdbc: + time_zone: UTC + flyway: + enabled: true + locations: classpath:db/migration + clean-disabled: true + data: + redis: + host: ${REDIS_HOST} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD} + connect-timeout: 2s + timeout: 2s + mail: + host: ${SMTP_HOST} + port: ${SMTP_PORT:587} + username: ${SMTP_USERNAME} + password: ${SMTP_PASSWORD} + properties: + mail: + smtp: + auth: ${SMTP_AUTH:true} + starttls: + enable: ${SMTP_STARTTLS_ENABLE:true} + required: ${SMTP_STARTTLS_REQUIRED:true} + ssl: + enable: ${SMTP_SSL_ENABLE:false} + connectiontimeout: 5000 + timeout: 5000 + writetimeout: 5000 + security: + oauth2: + base-url: ${OAUTH2_BASE_URL:https://redot-api.dhxxn.kr} + client: + registration: + redot-member-google: + provider: google + client-id: ${GOOGLE_CLIENT_ID} + client-secret: ${GOOGLE_CLIENT_SECRET} + authorization-grant-type: authorization_code + redirect-uri: ${GOOGLE_REDIRECT_URI} + scope: ${GOOGLE_SCOPES:openid,profile,email} +jwt: + secret: ${JWT_SECRET} + cookie: + secure: true + domain: ${JWT_COOKIE_DOMAIN:redot-api.dhxxn.kr} + same-site: ${JWT_COOKIE_SAME_SITE:Lax} +cloud: + aws: + region: + static: ${AWS_REGION} + credentials: + access-key: ${AWS_ACCESS_KEY_ID} + secret-key: ${AWS_SECRET_ACCESS_KEY} + s3: + bucket: ${S3_BUCKET} +image: + cdn-base-url: ${IMAGE_CDN_BASE_URL:} + upload: + max-size-bytes: ${IMAGE_MAX_SIZE_BYTES:5242880} + allowed-content-types: ${IMAGE_ALLOWED_CONTENT_TYPES:image/*} +email: + verification: + from-address: ${MAIL_FROM} + brand-name: ${MAIL_BRAND_NAME:Redot} + code-length: ${EMAIL_CODE_LENGTH:6} + code-ttl-seconds: ${EMAIL_CODE_TTL_SECONDS:300} + token-ttl-seconds: ${EMAIL_TOKEN_TTL_SECONDS:900} + resend-cooldown-seconds: ${EMAIL_RESEND_COOLDOWN_SECONDS:5} +auth: + flows: + redot-member: + default-success-redirect: ${AUTH_SUCCESS_REDIRECT} + default-failure-redirect: ${AUTH_FAILURE_REDIRECT} + allowed-redirect-hosts: ${AUTH_ALLOWED_REDIRECT_HOSTS} +notifications: + consultation: + admin-console-base-url: ${ADMIN_CONSOLE_BASE_URL:} + discord: + webhook-url: ${DISCORD_WEBHOOK_URL:} +redot: + health: + public-enabled: true + cors: + allowed-origins: ${CORS_ALLOWED_ORIGINS} +management: + endpoints: + access: + default: none + web: + exposure: + include: health + endpoint: + health: + access: read-only + show-details: never + show-components: never + probes: + enabled: true + group: + liveness: + include: livenessState + readiness: + include: readinessState,db,redis + health: + mail: + enabled: false +springdoc: + api-docs: + enabled: false + swagger-ui: + enabled: false +logging: + level: + root: ${LOG_LEVEL:INFO} + org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration: ERROR diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 548f512..846a80c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,4 +1,11 @@ spring: profiles: default: dev + group: + homelab: prod +# Actuator is opt-in for the new homelab profile; existing prod/dev do not expose it. +management: + endpoints: + access: + default: none diff --git a/src/test/java/homelabtests/HealthProbeTests.java b/src/test/java/homelabtests/HealthProbeTests.java new file mode 100644 index 0000000..1d08c7d --- /dev/null +++ b/src/test/java/homelabtests/HealthProbeTests.java @@ -0,0 +1,119 @@ +package homelabtests; + +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.health.Health; +import org.springframework.boot.actuate.health.HealthIndicator; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration; +import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; +import org.springframework.boot.autoconfigure.security.oauth2.client.servlet.OAuth2ClientAutoConfiguration; +import org.springframework.boot.availability.AvailabilityChangeEvent; +import org.springframework.boot.availability.LivenessState; +import org.springframework.boot.availability.ReadinessState; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; +import redot.redot_server.global.security.config.ActuatorSecurityConfig; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest(classes = HealthProbeTests.ProbeApplication.class, properties = { + "redot.health.public-enabled=true", "management.endpoint.health.access=read-only", + "management.endpoints.web.exposure.include=health,env", + "management.endpoint.env.access=read-only", "management.endpoint.health.probes.enabled=true", + "management.endpoint.health.show-details=never", "management.endpoint.health.show-components=never", + "management.endpoint.health.group.liveness.include=livenessState", + "management.endpoint.health.group.readiness.include=readinessState,db,redis" +}) +@AutoConfigureMockMvc +class HealthProbeTests { + @Autowired MockMvc mvc; + @Autowired ApplicationContext context; + @Autowired Dependencies dependencies; + + @BeforeEach void reset() { + dependencies.db.set(true); + dependencies.redis.set(true); + AvailabilityChangeEvent.publish(context, LivenessState.CORRECT); + AvailabilityChangeEvent.publish(context, ReadinessState.ACCEPTING_TRAFFIC); + } + + @Test void healthyProbesAreAnonymousAndDoNotRevealComponents() throws Exception { + for (String path : new String[]{"/actuator/health", "/actuator/health/liveness", "/actuator/health/readiness"}) { + mvc.perform(get(path)).andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.components").doesNotExist()) + .andExpect(jsonPath("$.details").doesNotExist()); + } + } + + @Test void databaseFailureDoesNotFailLiveness() throws Exception { + dependencies.db.set(false); + mvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk()); + mvc.perform(get("/actuator/health/readiness")).andExpect(status().isServiceUnavailable()); + } + + @Test void redisFailureDoesNotFailLiveness() throws Exception { + dependencies.redis.set(false); + mvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk()); + mvc.perform(get("/actuator/health/readiness")).andExpect(status().isServiceUnavailable()); + } + + @Test void refusingTrafficAffectsReadinessOnly() throws Exception { + AvailabilityChangeEvent.publish(context, ReadinessState.REFUSING_TRAFFIC); + mvc.perform(get("/actuator/health/readiness")).andExpect(status().isServiceUnavailable()); + mvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk()); + } + + @Test void brokenApplicationFailsLiveness() throws Exception { + AvailabilityChangeEvent.publish(context, LivenessState.BROKEN); + mvc.perform(get("/actuator/health/liveness")).andExpect(status().isServiceUnavailable()); + } + + @Test void sensitiveAndUnlistedEndpointsRemainDeniedEvenIfExposed() throws Exception { + for (String path : new String[]{"/actuator/env", "/actuator/configprops", "/actuator/heapdump", + "/actuator/health/db", "/actuator"}) { + mvc.perform(get(path)).andExpect(status().isForbidden()); + } + mvc.perform(post("/actuator/health")).andExpect(status().isForbidden()); + } + + @Test void healthChainDoesNotAuthorizeOtherApiPaths() throws Exception { + mvc.perform(get("/api/protected")).andExpect(status().isForbidden()); + } + + static class Dependencies { + final AtomicBoolean db = new AtomicBoolean(true); + final AtomicBoolean redis = new AtomicBoolean(true); + } + + @Configuration(proxyBeanMethods = false) + @EnableAutoConfiguration(exclude = {DataSourceAutoConfiguration.class, RedisAutoConfiguration.class, + UserDetailsServiceAutoConfiguration.class, OAuth2ClientAutoConfiguration.class}) + @Import(ActuatorSecurityConfig.class) + static class ProbeApplication { + @Bean Dependencies dependencies() { return new Dependencies(); } + @Bean HealthIndicator dbHealthIndicator(Dependencies d) { + return () -> d.db.get() ? Health.up().build() : Health.down().build(); + } + @Bean HealthIndicator redisHealthIndicator(Dependencies d) { + return () -> d.redis.get() ? Health.up().build() : Health.down().build(); + } + @Bean @Order(0) SecurityFilterChain protectedApi(HttpSecurity http) throws Exception { + return http.authorizeHttpRequests(auth -> auth.anyRequest().denyAll()).build(); + } + } +} diff --git a/src/test/java/homelabtests/HomelabCorsTests.java b/src/test/java/homelabtests/HomelabCorsTests.java new file mode 100644 index 0000000..bfa9ebd --- /dev/null +++ b/src/test/java/homelabtests/HomelabCorsTests.java @@ -0,0 +1,38 @@ +package homelabtests; + +import jakarta.servlet.Filter; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import redot.redot_server.global.security.config.HomelabCorsConfig; +import static org.assertj.core.api.Assertions.*; + +class HomelabCorsTests { + @Test void explicitOriginCanSendCredentialedApiPreflight() throws Exception { + Filter filter = new HomelabCorsConfig().homelabCorsFilter("https://frontend.example.invalid").getFilter(); + MockHttpServletRequest req = new MockHttpServletRequest("OPTIONS", "/api/v1/auth/redot/member/sign-in"); + req.addHeader("Origin", "https://frontend.example.invalid"); + req.addHeader("Access-Control-Request-Method", "POST"); + req.addHeader("Access-Control-Request-Headers", "Content-Type,X-App-Subdomain"); + MockHttpServletResponse res = new MockHttpServletResponse(); + filter.doFilter(req, res, (a, b) -> { throw new AssertionError("Preflight must be handled before API authentication"); }); + assertThat(res.getStatus()).isEqualTo(200); + assertThat(res.getHeader("Access-Control-Allow-Origin")).isEqualTo("https://frontend.example.invalid"); + assertThat(res.getHeader("Access-Control-Allow-Credentials")).isEqualTo("true"); + } + @Test void unlistedOriginIsRejected() throws Exception { + Filter filter = new HomelabCorsConfig().homelabCorsFilter("https://frontend.example.invalid").getFilter(); + MockHttpServletRequest req = new MockHttpServletRequest("OPTIONS", "/api/v1/app"); + req.addHeader("Origin", "https://other.example.invalid"); + req.addHeader("Access-Control-Request-Method", "GET"); + MockHttpServletResponse res = new MockHttpServletResponse(); + filter.doFilter(req, res, (a, b) -> {}); + assertThat(res.getStatus()).isEqualTo(403); + } + @Test void wildcardAndMalformedOriginsFailClosed() { + for (String value : new String[]{"*", "https://*.example.invalid", "", "https://example.invalid/path"}) { + assertThatThrownBy(() -> new HomelabCorsConfig().homelabCorsFilter(value)) + .isInstanceOf(IllegalArgumentException.class); + } + } +} diff --git a/src/test/java/homelabtests/HomelabProfileTests.java b/src/test/java/homelabtests/HomelabProfileTests.java new file mode 100644 index 0000000..c250b8f --- /dev/null +++ b/src/test/java/homelabtests/HomelabProfileTests.java @@ -0,0 +1,31 @@ +package homelabtests; + +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.springframework.boot.env.YamlPropertySourceLoader; +import org.springframework.core.env.MapPropertySource; +import org.springframework.core.env.StandardEnvironment; +import org.springframework.core.io.ClassPathResource; +import static org.assertj.core.api.Assertions.assertThat; + +class HomelabProfileTests { + @Test void runtimeOverridesResolveWithoutAnEmbeddedProductionProfile() throws Exception { + StandardEnvironment env = new StandardEnvironment(); + env.getPropertySources().addFirst(new MapPropertySource("synthetic-runtime", Map.of( + "DB_HOST", "db.example.invalid", "DB_PORT", "15432", "DB_NAME", "contract", + "DB_USERNAME", "runtime-role", "AWS_REGION", "ap-northeast-2", + "JWT_COOKIE_DOMAIN", "api.example.invalid", "JWT_COOKIE_SAME_SITE", "Strict"))); + var sources = new YamlPropertySourceLoader().load("homelab", new ClassPathResource("application-homelab.yml")); + sources.forEach(source -> env.getPropertySources().addLast(source)); + assertThat(env.getProperty("spring.datasource.url")).isEqualTo("jdbc:postgresql://db.example.invalid:15432/contract"); + assertThat(env.getProperty("spring.datasource.username")).isEqualTo("runtime-role"); + assertThat(env.getProperty("cloud.aws.region.static")).isEqualTo("ap-northeast-2"); + assertThat(env.getProperty("jwt.cookie.domain")).isEqualTo("api.example.invalid"); + assertThat(env.getProperty("jwt.cookie.same-site")).isEqualTo("Strict"); + assertThat(env.getProperty("spring.jpa.hibernate.ddl-auto")).isEqualTo("validate"); + assertThat(env.getProperty("spring.flyway.enabled")).isEqualTo("true"); + assertThat(env.getProperty("management.endpoint.health.group.liveness.include")).isEqualTo("livenessState"); + assertThat(env.getProperty("management.endpoint.health.group.readiness.include")).isEqualTo("readinessState,db,redis"); + assertThat(env.getProperty("springdoc.api-docs.enabled")).isEqualTo("false"); + } +} diff --git a/src/test/java/homelabtests/LegacyHealthSecurityTests.java b/src/test/java/homelabtests/LegacyHealthSecurityTests.java new file mode 100644 index 0000000..b3b7100 --- /dev/null +++ b/src/test/java/homelabtests/LegacyHealthSecurityTests.java @@ -0,0 +1,20 @@ +package homelabtests; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(classes = HealthProbeTests.ProbeApplication.class, properties = { + "management.endpoint.health.access=read-only", "management.endpoints.web.exposure.include=health" +}) +@AutoConfigureMockMvc +class LegacyHealthSecurityTests { + @Autowired MockMvc mvc; + @Test void healthIsNotPublicWithoutExplicitHomelabOptIn() throws Exception { + mvc.perform(get("/actuator/health")).andExpect(status().isForbidden()); + } +} diff --git a/src/test/java/redot/redot_server/HomelabApplicationTests.java b/src/test/java/redot/redot_server/HomelabApplicationTests.java new file mode 100644 index 0000000..cc45500 --- /dev/null +++ b/src/test/java/redot/redot_server/HomelabApplicationTests.java @@ -0,0 +1,128 @@ +package redot.redot_server; + +import java.util.UUID; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.web.FilterChainProxy; +import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; +import org.springframework.test.web.servlet.MockMvc; +import redot.redot_server.global.security.social.resolver.FlowAwareRedirectUriResolver; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import java.time.Duration; +import static org.assertj.core.api.Assertions.assertThat; + +// This remains a full application context test, now with explicit isolated dependencies. +// Run using scripts/test-kubernetes.py; no production credentials are required. +@Tag("integration") +@ActiveProfiles("homelab") +@SpringBootTest +@AutoConfigureMockMvc +class HomelabApplicationTests { + @Autowired JdbcTemplate jdbc; + @Autowired FlowAwareRedirectUriResolver oauthResolver; + @Autowired FilterChainProxy security; + @Autowired MockMvc mvc; + @Autowired StringRedisTemplate redis; + private static final String GENERATED_TEST_VALUE = UUID.randomUUID().toString() + UUID.randomUUID(); + + @DynamicPropertySource + static void runtime(DynamicPropertyRegistry properties) { + properties.add("DB_HOST", () -> requiredTestEnv("REDOT_IT_DB_HOST")); + properties.add("DB_PORT", () -> "5432"); + properties.add("DB_NAME", () -> "redot_test"); + properties.add("DB_USERNAME", () -> "redot_test"); + properties.add("DB_PASSWORD", () -> requiredTestEnv("REDOT_IT_DB_PASSWORD")); + properties.add("REDIS_HOST", () -> requiredTestEnv("REDOT_IT_REDIS_HOST")); + properties.add("REDIS_PASSWORD", () -> requiredTestEnv("REDOT_IT_REDIS_PASSWORD")); + for (String name : new String[]{"JWT_SECRET", "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", + "SMTP_USERNAME", "SMTP_PASSWORD", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"}) { + properties.add(name, () -> GENERATED_TEST_VALUE); + } + properties.add("AWS_REGION", () -> "ap-northeast-2"); + properties.add("S3_BUCKET", () -> "redot-test-not-a-real-bucket"); + properties.add("SMTP_HOST", () -> "smtp.example.invalid"); + properties.add("MAIL_FROM", () -> "test@example.invalid"); + properties.add("GOOGLE_REDIRECT_URI", () -> "https://redot-api.dhxxn.kr/api/v1/sign-in/redot-member/social/callback/redot-member-google"); + properties.add("AUTH_SUCCESS_REDIRECT", () -> "https://frontend.example.invalid/success"); + properties.add("AUTH_FAILURE_REDIRECT", () -> "https://frontend.example.invalid/failure"); + properties.add("AUTH_ALLOWED_REDIRECT_HOSTS", () -> "frontend.example.invalid"); + properties.add("CORS_ALLOWED_ORIGINS", () -> "https://frontend.example.invalid"); + } + + private static String requiredTestEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + throw new IllegalStateException("Run scripts/test-kubernetes.py to provide isolated test dependencies: " + name); + } + return value; + } + + @Test + void contextLoads() { + // Flyway has run and Hibernate ddl-auto=validate has completed before this method. + assertThat(jdbc.queryForObject("SHOW server_version", String.class)).startsWith("17.11"); + assertThat(jdbc.queryForObject("SELECT count(*) FROM flyway_schema_history WHERE success", Integer.class)).isEqualTo(7); + assertThat(jdbc.queryForList("SELECT version FROM flyway_schema_history ORDER BY installed_rank", String.class)) + .containsExactly("1", "2", "3", "4", "5", "6", "7"); + } + + @Test + void redisAcceptsExpiringVerificationState() { + String key = "redot-integration:" + UUID.randomUUID(); + try { + redis.opsForValue().set(key, GENERATED_TEST_VALUE, Duration.ofSeconds(60)); + assertThat(GENERATED_TEST_VALUE.equals(redis.opsForValue().get(key))).isTrue(); + assertThat(redis.getExpire(key)).isBetween(1L, 60L); + } finally { + redis.delete(key); + } + } + private MockHttpServletRequest oauthRequest(String path) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", path); + request.setServletPath(path); + request.setScheme("https"); + request.setServerName("redot-api.dhxxn.kr"); + request.setServerPort(443); + request.setSecure(true); + return request; + } + + @Test + void googleCallbackMatchesRegistrationAndSecurityChain() { + String callbackPath = "/api/v1/sign-in/redot-member/social/callback/redot-member-google"; + var request = oauthRequest("/api/v1/oauth2/authorization/redot-member-google"); + var authorization = oauthResolver.resolve(request); + assertThat(authorization).isNotNull(); + assertThat(authorization.getRedirectUri()).isEqualTo("https://redot-api.dhxxn.kr" + callbackPath); + var callback = oauthRequest(callbackPath); + var selected = security.getFilterChains().stream().filter(chain -> chain.matches(callback)).findFirst().orElseThrow(); + assertThat(selected.getFilters().stream().anyMatch(OAuth2LoginAuthenticationFilter.class::isInstance)).isTrue(); + } + + @Test + void existingFrontendOverrideBehaviorIsExplicitAndUnchanged() { + var request = oauthRequest("/api/v1/oauth2/authorization/redot-member-google"); + request.setParameter("redirect_uri", "https://frontend.example.invalid/success"); + assertThat(oauthResolver.resolve(request).getRedirectUri()).isEqualTo( + "https://frontend.example.invalid/api/v1/sign-in/redot-member/social/callback/redot-member-google"); + } + + @Test + void realContextHasReadyProbesButProtectedMemberApi() throws Exception { + mvc.perform(get("/actuator/health/liveness")).andExpect(status().isOk()); + mvc.perform(get("/actuator/health/readiness")).andExpect(status().isOk()); + mvc.perform(get("/actuator/env")).andExpect(status().isForbidden()); + mvc.perform(get("/api/v1/auth/redot/member/me")).andExpect(status().isUnauthorized()); + } + +} diff --git a/src/test/java/redot/redot_server/RedotServerApplicationTests.java b/src/test/java/redot/redot_server/RedotServerApplicationTests.java index e878e3d..a6d9cf5 100644 --- a/src/test/java/redot/redot_server/RedotServerApplicationTests.java +++ b/src/test/java/redot/redot_server/RedotServerApplicationTests.java @@ -1,6 +1,8 @@ package redot.redot_server; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Tag; +import redot.redot_server.support.RuntimePostgres; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; @@ -16,12 +18,13 @@ @SpringBootTest @ActiveProfiles("test") +@Tag("container") @Testcontainers @Import(RedotServerApplicationTests.TestCookies.class) class RedotServerApplicationTests { @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + static final PostgreSQLContainer POSTGRES = RuntimePostgres.create(); @DynamicPropertySource static void databaseProperties(DynamicPropertyRegistry registry) { diff --git a/src/test/java/redot/redot_server/domain/cms/inquiry/service/InquiryConcurrencyTest.java b/src/test/java/redot/redot_server/domain/cms/inquiry/service/InquiryConcurrencyTest.java index 3f594dc..9da8cde 100644 --- a/src/test/java/redot/redot_server/domain/cms/inquiry/service/InquiryConcurrencyTest.java +++ b/src/test/java/redot/redot_server/domain/cms/inquiry/service/InquiryConcurrencyTest.java @@ -15,6 +15,8 @@ import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Tag; +import redot.redot_server.support.RuntimePostgres; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; @@ -52,11 +54,12 @@ @Import({QueryDSLConfig.class, InquiryNumberGenerator.class, RedotAppInquiryService.class, InquiryConcurrencyTest.TimeConfiguration.class}) @Transactional(propagation = Propagation.NOT_SUPPORTED) +@Tag("container") @Testcontainers class InquiryConcurrencyTest { @Container - static final PostgreSQLContainer POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine"); + static final PostgreSQLContainer POSTGRES = RuntimePostgres.create(); @DynamicPropertySource static void databaseProperties(DynamicPropertyRegistry registry) { diff --git a/src/test/java/redot/redot_server/support/RuntimePostgres.java b/src/test/java/redot/redot_server/support/RuntimePostgres.java new file mode 100644 index 0000000..3215785 --- /dev/null +++ b/src/test/java/redot/redot_server/support/RuntimePostgres.java @@ -0,0 +1,25 @@ +package redot.redot_server.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.nio.file.Path; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.utility.DockerImageName; + +public final class RuntimePostgres { + private RuntimePostgres() {} + + public static PostgreSQLContainer create() { + try { + var image = new ObjectMapper().readTree(Path.of("docs/runtime-images.json").toFile()).get("postgresql"); + // Testcontainers 1.20 parses repository@digest, but not repository:tag@digest. + String reference = image.get("image").asText(); + String digestReference = reference.replaceFirst(":([^/@]+)@", "@"); + var container = new PostgreSQLContainer<>(DockerImageName.parse(digestReference)); + container.withCreateContainerCmdModifier(cmd -> cmd.withPlatform(image.get("platform").asText())); + return container; + } catch (IOException ex) { + throw new IllegalStateException("Cannot read pinned PostgreSQL runtime image", ex); + } + } +}