From a56d68896fcf24806a2cd18185ea5bfd3f0498f0 Mon Sep 17 00:00:00 2001 From: Lectom Date: Thu, 18 Jun 2026 11:02:48 +0900 Subject: [PATCH] =?UTF-8?q?production=20=ED=91=B8=EC=8B=9C=20=EC=B4=88?= =?UTF-8?q?=EC=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/production_image_deploy.yml | 96 +++ .gitea/workflows/production_image_publish.yml | 182 +++++ .gitea/workflows/staging_image_deploy.yml | 94 +++ Makefile | 18 +- ...GrainedPermissionsPage.superAdmin.test.tsx | 35 + .../TenantFineGrainedPermissionsPage.tsx | 357 ++++++++-- .../tenant-profile-performance-local.spec.ts | 39 +- config/traefik-compose.yml | 69 ++ deploy/create-instance.sh | 32 +- deploy/deploy_guide.md | 52 ++ deploy/templates/.env.template | 8 + deploy/templates/devfront/vite.config.ts | 3 +- deploy/templates/docker-compose.images.yaml | 392 +++++++++++ deploy/templates/docker-compose.yaml | 59 +- devfront/Dockerfile | 8 +- devfront/scripts/runtime-mode.sh | 4 +- devfront/vite.config.ts | 3 +- docker-compose.yaml | 4 +- docker/docker-compose.staging.template.yaml | 2 +- docker/docker-compose.template.yaml | 2 +- docker/staging_pull_compose.template.yaml | 4 +- docs/backup-restore-design.md | 16 +- .../traefik-production-rp-bootstrap-design.md | 190 ++++++ docs/works-drive-docker-image-archive.md | 121 ++++ scripts/backup/refresh_works_drive_token.sh | 243 +++++++ scripts/backup/upload_cloud.sh | 41 +- scripts/deploy/build_image_deploy_bundle.sh | 162 +++++ scripts/deploy/upload_and_run_image_deploy.sh | 39 ++ scripts/docker-image/upload_works_drive.sh | 632 ++++++++++++++++++ test/backup_make_targets_test.sh | 15 +- test/backup_upload_cloud_policy_test.sh | 126 +++- test/devfront_port_policy_test.sh | 75 +++ test/prod_deploy_traefik_policy_test.sh | 70 ++ .../production_image_workflows_policy_test.sh | 126 ++++ ...traefik_forward_auth_config_policy_test.sh | 46 ++ ...s_drive_docker_image_upload_policy_test.sh | 220 ++++++ test/works_drive_refresh_token_policy_test.sh | 102 +++ 37 files changed, 3573 insertions(+), 114 deletions(-) create mode 100644 .gitea/workflows/production_image_deploy.yml create mode 100644 .gitea/workflows/production_image_publish.yml create mode 100644 .gitea/workflows/staging_image_deploy.yml create mode 100644 config/traefik-compose.yml create mode 100644 deploy/templates/docker-compose.images.yaml create mode 100644 docs/traefik-production-rp-bootstrap-design.md create mode 100644 docs/works-drive-docker-image-archive.md create mode 100755 scripts/backup/refresh_works_drive_token.sh create mode 100755 scripts/deploy/build_image_deploy_bundle.sh create mode 100755 scripts/deploy/upload_and_run_image_deploy.sh create mode 100755 scripts/docker-image/upload_works_drive.sh create mode 100644 test/devfront_port_policy_test.sh create mode 100644 test/prod_deploy_traefik_policy_test.sh create mode 100644 test/production_image_workflows_policy_test.sh create mode 100644 test/traefik_forward_auth_config_policy_test.sh create mode 100755 test/works_drive_docker_image_upload_policy_test.sh create mode 100755 test/works_drive_refresh_token_policy_test.sh diff --git a/.gitea/workflows/production_image_deploy.yml b/.gitea/workflows/production_image_deploy.yml new file mode 100644 index 00000000..dfa30803 --- /dev/null +++ b/.gitea/workflows/production_image_deploy.yml @@ -0,0 +1,96 @@ +name: Deploy Baron SSO Production Images + +on: + workflow_dispatch: + inputs: + image_tag: + description: "배포할 공용 저장소 이미지 태그 (예: v1.2606.ab12)" + required: true + type: string + +jobs: + deploy-production-images: + runs-on: ubuntu-latest + steps: + - name: Checkout deployment scripts and templates + uses: actions/checkout@v4 + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }} + + - name: Build production deployment bundle + env: + IMAGE_TAG: ${{ github.event.inputs.image_tag }} + IMAGE_DEPLOY_ENV: production + IMAGE_DEPLOY_INSTANCE_NAME: ${{ vars.PROD_INSTANCE_NAME }} + IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.PROD_PORT_PREFIX }} + IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.PROD_FRONTEND_URL }} + IMAGE_DEPLOY_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml + IMAGE_DEPLOY_BUNDLE_FILE: prod-image-deploy-bundle.tgz + ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }} + DEVFRONT_URL: ${{ vars.DEVFRONT_URL }} + ORGFRONT_URL: ${{ vars.ORGFRONT_URL }} + VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }} + IMAGE_DEPLOY_DB_PORT: ${{ vars.PROD_DB_PORT }} + IMAGE_DEPLOY_REDIS_PORT: ${{ vars.PROD_REDIS_PORT }} + IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.PROD_CLICKHOUSE_PORT_HTTP }} + IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.PROD_CLICKHOUSE_PORT_NATIVE }} + IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.PROD_BACKEND_PORT }} + IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.PROD_FRONTEND_PORT }} + ADMINFRONT_PORT: ${{ vars.ADMINFRONT_PORT }} + DEVFRONT_PORT: ${{ vars.DEVFRONT_PORT }} + ORGFRONT_PORT: ${{ vars.ORGFRONT_PORT }} + IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.PROD_OATHKEEPER_PROXY_PORT }} + IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.PROD_DOMAIN_SUFFIX }} + ADMINFRONT_CALLBACK_URLS: ${{ vars.ADMINFRONT_CALLBACK_URLS }} + DEVFRONT_CALLBACK_URLS: ${{ vars.DEVFRONT_CALLBACK_URLS }} + ORGFRONT_CALLBACK_URLS: ${{ vars.ORGFRONT_CALLBACK_URLS }} + HYDRA_REFRESH_TOKEN_TTL: ${{ vars.HYDRA_REFRESH_TOKEN_TTL }} + ORY_POSTGRES_USER: ${{ vars.ORY_POSTGRES_USER }} + ORY_POSTGRES_DB: ${{ vars.ORY_POSTGRES_DB }} + KRATOS_DB: ${{ vars.KRATOS_DB }} + HYDRA_DB: ${{ vars.HYDRA_DB }} + KETO_DB: ${{ vars.KETO_DB }} + KRATOS_VERSION: ${{ vars.KRATOS_VERSION }} + HYDRA_VERSION: ${{ vars.HYDRA_VERSION }} + KETO_VERSION: ${{ vars.KETO_VERSION }} + OATHKEEPER_VERSION: ${{ vars.OATHKEEPER_VERSION }} + ORY_POSTGRES_TAG: ${{ vars.ORY_POSTGRES_TAG }} + OATHKEEPER_UID: ${{ vars.OATHKEEPER_UID }} + OATHKEEPER_GID: ${{ vars.OATHKEEPER_GID }} + OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }} + ADMIN_EMAIL: ${{ vars.ADMIN_EMAIL }} + HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }} + BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend + USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront + ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront + DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront + ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront + IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.PROD_DB_PASSWORD }} + IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.PROD_ORY_POSTGRES_PASSWORD }} + IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.PROD_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} + IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.PROD_CLICKHOUSE_PASSWORD }} + IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.PROD_COOKIE_SECRET }} + IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.PROD_JWT_SECRET }} + IMAGE_DEPLOY_CSRF_COOKIE_SECRET: ${{ secrets.PROD_CSRF_COOKIE_SECRET }} + IMAGE_DEPLOY_ADMIN_PASSWORD: ${{ secrets.PROD_ADMIN_PASSWORD }} + run: | + set -euo pipefail + # Same image tag contract as staging: production must consume the + # immutable image tag that already passed staging verification. + scripts/deploy/build_image_deploy_bundle.sh + + - name: Upload bundle and run requested production image tag + env: + IMAGE_DEPLOY_BUNDLE_FILE: prod-image-deploy-bundle.tgz + DEPLOY_HOST: ${{ vars.PROD_HOST }} + DEPLOY_USER: ${{ vars.PROD_USER }} + DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }} + HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }} + HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }} + HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }} + run: | + set -euo pipefail + scripts/deploy/upload_and_run_image_deploy.sh diff --git a/.gitea/workflows/production_image_publish.yml b/.gitea/workflows/production_image_publish.yml new file mode 100644 index 00000000..af07e102 --- /dev/null +++ b/.gitea/workflows/production_image_publish.yml @@ -0,0 +1,182 @@ +name: Publish Baron SSO Production Images + +on: + workflow_dispatch: + inputs: + version_prefix: + description: "공용 저장소 이미지 태그 prefix (예: v1.2606, 최종 태그는 v1.2606.<커밋해시4자리>)" + required: true + type: string + +jobs: + publish-images: + runs-on: ubuntu-latest + steps: + - name: Checkout dev branch + uses: actions/checkout@v4 + with: + ref: dev + + - name: Validate publish inputs + env: + VERSION_PREFIX: ${{ github.event.inputs.version_prefix }} + HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }} + HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }} + HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }} + HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }} + ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }} + DEVFRONT_URL: ${{ vars.DEVFRONT_URL }} + ORGFRONT_URL: ${{ vars.ORGFRONT_URL }} + VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }} + run: | + set -euo pipefail + + if ! printf '%s' "${VERSION_PREFIX}" | grep -Eq '^v[0-9]+\.[0-9]{4}$'; then + echo "::error::version_prefix must look like vX.YYMM (got: ${VERSION_PREFIX})" + exit 1 + fi + + required_values=" + HARBOR_ENDPOINT HARBOR_HOSTNAME HARBOR_ROBOT_ACCOUNT HARBOR_ROBOT_KEY + ADMINFRONT_URL DEVFRONT_URL ORGFRONT_URL VITE_OIDC_AUTHORITY + " + for key in ${required_values}; do + if [ -z "${!key:-}" ]; then + echo "::error::Missing required publish value: ${key}. Check Gitea repo variables/secrets." + exit 1 + fi + done + + - name: Compute commit-hash image tag + id: version + env: + VERSION_PREFIX: ${{ github.event.inputs.version_prefix }} + run: | + set -euo pipefail + + short_sha="$(git rev-parse --short=4 HEAD)" + if ! printf '%s' "${short_sha}" | grep -Eq '^[0-9a-f]{4}$'; then + echo "::error::commit hash suffix must be 4 lowercase hexadecimal characters (got: ${short_sha})" + exit 1 + fi + + image_tag="${VERSION_PREFIX}.${short_sha}" + echo "image_tag=${image_tag}" >> "${GITHUB_OUTPUT}" + echo "Computed production image tag: ${image_tag}" + + - name: Login to shared registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.HARBOR_ENDPOINT }} + username: ${{ vars.HARBOR_ROBOT_ACCOUNT }} + password: ${{ secrets.HARBOR_ROBOT_KEY }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push backend production image + uses: docker/build-push-action@v5 + with: + context: ./backend + file: ./backend/Dockerfile + push: true + tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend:${{ steps.version.outputs.image_tag }} + provenance: false + sbom: false + + - name: Build and push userfront production image + uses: docker/build-push-action@v5 + with: + context: . + file: ./userfront/Dockerfile + target: production + push: true + tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront:${{ steps.version.outputs.image_tag }} + provenance: false + sbom: false + + - name: Build and push adminfront production image + uses: docker/build-push-action@v5 + with: + context: . + file: ./adminfront/Dockerfile + target: production + push: true + tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront:${{ steps.version.outputs.image_tag }} + build-args: | + VITE_ADMIN_PUBLIC_URL=${{ vars.ADMINFRONT_URL }} + VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }} + VITE_OIDC_CLIENT_ID=adminfront + ORGFRONT_URL=${{ vars.ORGFRONT_URL }} + provenance: false + sbom: false + + - name: Build and push devfront production image + uses: docker/build-push-action@v5 + with: + context: . + file: ./devfront/Dockerfile + target: production + push: true + tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront:${{ steps.version.outputs.image_tag }} + build-args: | + VITE_DEVFRONT_PUBLIC_URL=${{ vars.DEVFRONT_URL }} + VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }} + VITE_OIDC_CLIENT_ID=devfront + ORGFRONT_URL=${{ vars.ORGFRONT_URL }} + provenance: false + sbom: false + + - name: Build and push orgfront production image + uses: docker/build-push-action@v5 + with: + context: . + file: ./orgfront/Dockerfile + target: production + push: true + tags: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront:${{ steps.version.outputs.image_tag }} + build-args: | + VITE_ORGFRONT_PUBLIC_URL=${{ vars.ORGFRONT_URL }} + VITE_OIDC_AUTHORITY=${{ vars.VITE_OIDC_AUTHORITY }} + VITE_OIDC_CLIENT_ID=orgfront + provenance: false + sbom: false + + - name: Upload pushed images to WORKS Drive archive + if: ${{ vars.WORKS_DRIVE_DOCKER_IMAGE_ARCHIVE_ENABLED == 'true' }} + env: + IMAGE_TAG: ${{ steps.version.outputs.image_tag }} + HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }} + WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR: ${{ vars.WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR }} + WORKS_DRIVE_TARGET: sharedrive + WORKS_DRIVE_SHARED_DRIVE_ID: ${{ vars.WORKS_DRIVE_SHARED_DRIVE_ID }} + WORKS_DRIVE_PARENT_FILE_ID: ${{ vars.WORKS_DRIVE_PARENT_FILE_ID }} + WORKS_DRIVE_OAUTH_CLIENT_ID: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_ID }} + WORKS_DRIVE_OAUTH_CLIENT_SECRET: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_SECRET }} + WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT }} + WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY: ${{ secrets.WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY }} + WORKS_DRIVE_OAUTH_REFRESH_TOKEN: ${{ secrets.WORKS_DRIVE_OAUTH_REFRESH_TOKEN }} + WORKS_ADMIN_API_BASE_URL: ${{ vars.WORKS_ADMIN_API_BASE_URL }} + WORKS_ADMIN_OAUTH_TOKEN_URL: ${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }} + run: | + set -euo pipefail + + : "${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:=docker-build-image}" + + required_values=" + IMAGE_TAG HARBOR_HOSTNAME WORKS_DRIVE_SHARED_DRIVE_ID + " + for key in ${required_values}; do + if [ -z "${!key:-}" ]; then + echo "::error::Missing required WORKS image archive value: ${key}." + exit 1 + fi + done + + for image in backend userfront adminfront devfront orgfront; do + image_ref="${HARBOR_HOSTNAME}/baron_sso/${image}:${IMAGE_TAG}" + docker pull "${image_ref}" + DOCKER_IMAGE_REF="${image_ref}" \ + WORKS_DOCKER_IMAGE_ARCHIVE_DIR="${RUNNER_TEMP}/baron-sso-docker-image-upload" \ + scripts/docker-image/upload_works_drive.sh + done diff --git a/.gitea/workflows/staging_image_deploy.yml b/.gitea/workflows/staging_image_deploy.yml new file mode 100644 index 00000000..5fafa7a5 --- /dev/null +++ b/.gitea/workflows/staging_image_deploy.yml @@ -0,0 +1,94 @@ +name: Deploy Baron SSO Staging Images + +on: + workflow_dispatch: + inputs: + image_tag: + description: "스테이징에 배포할 공용 저장소 이미지 태그 (예: v1.2606.ab12)" + required: true + type: string + +jobs: + deploy-staging-images: + runs-on: ubuntu-latest + steps: + - name: Checkout deployment scripts and templates + uses: actions/checkout@v4 + + - name: Setup SSH + uses: webfactory/ssh-agent@v0.9.0 + with: + ssh-private-key: ${{ secrets.STAGE_SSH_PRIVATE_KEY }} + + - name: Build staging deployment bundle + env: + IMAGE_TAG: ${{ github.event.inputs.image_tag }} + IMAGE_DEPLOY_ENV: stage + IMAGE_DEPLOY_INSTANCE_NAME: ${{ vars.STAGE_INSTANCE_NAME }} + IMAGE_DEPLOY_PORT_PREFIX: ${{ vars.STAGE_PORT_PREFIX }} + IMAGE_DEPLOY_PUBLIC_URL: ${{ vars.USERFRONT_URL }} + IMAGE_DEPLOY_COMPOSE_TEMPLATE: deploy/templates/docker-compose.images.yaml + IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz + ADMINFRONT_URL: ${{ vars.ADMINFRONT_URL }} + DEVFRONT_URL: ${{ vars.DEVFRONT_URL }} + ORGFRONT_URL: ${{ vars.ORGFRONT_URL }} + VITE_OIDC_AUTHORITY: ${{ vars.VITE_OIDC_AUTHORITY }} + IMAGE_DEPLOY_DB_PORT: ${{ vars.DB_PORT }} + IMAGE_DEPLOY_REDIS_PORT: ${{ vars.REDIS_PORT }} + IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP: ${{ vars.CLICKHOUSE_PORT_HTTP }} + IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE: ${{ vars.CLICKHOUSE_PORT_NATIVE }} + IMAGE_DEPLOY_BACKEND_PORT: ${{ vars.BACKEND_PORT }} + IMAGE_DEPLOY_FRONTEND_PORT: ${{ vars.USERFRONT_PORT }} + ADMINFRONT_PORT: ${{ vars.ADMINFRONT_PORT }} + DEVFRONT_PORT: ${{ vars.DEVFRONT_PORT }} + ORGFRONT_PORT: ${{ vars.ORGFRONT_PORT }} + IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT: ${{ vars.OATHKEEPER_PROXY_PORT }} + IMAGE_DEPLOY_DOMAIN_SUFFIX: ${{ vars.DOMAIN_SUFFIX }} + ADMINFRONT_CALLBACK_URLS: ${{ vars.ADMINFRONT_CALLBACK_URLS }} + DEVFRONT_CALLBACK_URLS: ${{ vars.DEVFRONT_CALLBACK_URLS }} + ORGFRONT_CALLBACK_URLS: ${{ vars.ORGFRONT_CALLBACK_URLS }} + HYDRA_REFRESH_TOKEN_TTL: ${{ vars.HYDRA_REFRESH_TOKEN_TTL }} + ORY_POSTGRES_USER: ${{ vars.ORY_POSTGRES_USER }} + ORY_POSTGRES_DB: ${{ vars.ORY_POSTGRES_DB }} + KRATOS_DB: ${{ vars.KRATOS_DB }} + HYDRA_DB: ${{ vars.HYDRA_DB }} + KETO_DB: ${{ vars.KETO_DB }} + KRATOS_VERSION: ${{ vars.KRATOS_VERSION }} + HYDRA_VERSION: ${{ vars.HYDRA_VERSION }} + KETO_VERSION: ${{ vars.KETO_VERSION }} + OATHKEEPER_VERSION: ${{ vars.OATHKEEPER_VERSION }} + ORY_POSTGRES_TAG: ${{ vars.ORY_POSTGRES_TAG }} + OATHKEEPER_UID: ${{ vars.OATHKEEPER_UID }} + OATHKEEPER_GID: ${{ vars.OATHKEEPER_GID }} + OATHKEEPER_INTROSPECT_CLIENT_ID: ${{ vars.OATHKEEPER_INTROSPECT_CLIENT_ID }} + ADMIN_EMAIL: ${{ vars.ADMIN_EMAIL }} + HARBOR_HOSTNAME: ${{ vars.HARBOR_HOSTNAME }} + BACKEND_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/backend + USERFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/userfront + ADMINFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/adminfront + DEVFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/devfront + ORGFRONT_IMAGE_NAME: ${{ vars.HARBOR_HOSTNAME }}/baron_sso/orgfront + IMAGE_DEPLOY_DB_PASSWORD: ${{ secrets.STG_DB_PASSWORD }} + IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD: ${{ secrets.STG_ORY_POSTGRES_PASSWORD }} + IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET: ${{ secrets.STG_OATHKEEPER_INTROSPECT_CLIENT_SECRET }} + IMAGE_DEPLOY_CLICKHOUSE_PASSWORD: ${{ secrets.CLICKHOUSE_PASSWORD }} + IMAGE_DEPLOY_COOKIE_SECRET: ${{ secrets.STG_COOKIE_SECRET }} + IMAGE_DEPLOY_JWT_SECRET: ${{ secrets.STG_JWT_SECRET }} + IMAGE_DEPLOY_CSRF_COOKIE_SECRET: ${{ secrets.STG_CSRF_COOKIE_SECRET }} + IMAGE_DEPLOY_ADMIN_PASSWORD: ${{ secrets.STG_ADMIN_PASSWORD }} + run: | + set -euo pipefail + scripts/deploy/build_image_deploy_bundle.sh + + - name: Upload bundle and run requested staging image tag + env: + IMAGE_DEPLOY_BUNDLE_FILE: stage-image-deploy-bundle.tgz + DEPLOY_HOST: ${{ vars.STAGE_HOST }} + DEPLOY_USER: ${{ vars.STAGE_USER }} + DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }} + HARBOR_ENDPOINT: ${{ vars.HARBOR_ENDPOINT }} + HARBOR_ROBOT_ACCOUNT: ${{ vars.HARBOR_ROBOT_ACCOUNT }} + HARBOR_ROBOT_KEY: ${{ secrets.HARBOR_ROBOT_KEY }} + run: | + set -euo pipefail + scripts/deploy/upload_and_run_image_deploy.sh diff --git a/Makefile b/Makefile index 1e6ee42c..a1a85749 100644 --- a/Makefile +++ b/Makefile @@ -47,8 +47,11 @@ ifneq (,$(wildcard ./$(AUTH_CONFIG_ENV))) BACKUP_DOCKER_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV) endif BACKUP_DOCKER_RUN = docker run --rm $(BACKUP_DOCKER_ENV_ARGS) -e BACKUP_REPO_ROOT=/workspace -v /var/run/docker.sock:/var/run/docker.sock -v "$(CURDIR)":/workspace -v /tmp:/tmp -w /workspace $(BACKUP_TOOLS_IMAGE) +DOCKER_IMAGE_REF ?= +WORKS_DOCKER_COMMIT_CONTAINER ?= +WORKS_DOCKER_IMAGE_ARCHIVE_DIR ?= /tmp/baron-sso-docker-image-upload -.PHONY: help build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory ensure-restore-containers up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump restore dump-verify restore-verify dump-list restore-plan upload-cloud dump-upload-cloud +.PHONY: help build-auth-config validate-auth-config verify-auth-config render-ory-config up up-all up-infra up-ory up-app up-backend ensure-networks ensure-infra ensure-ory ensure-restore-containers up-dev up-front-dev dev dev-debug down drop down-app down-backend down-infra down-ory check-infra ps logs-infra logs-ory logs-app backup-tools-build dump restore dump-verify restore-verify dump-list restore-plan upload-cloud works-drive-refresh-token dump-upload-cloud docker-image-upload-works help: ## 생성된 타깃과 옵션 목록 표시 @printf "Usage:\n make [OPTION=value ...]\n\n" @@ -329,7 +332,10 @@ restore-plan: backup-tools-build ## 복구 실행 계획 출력 $(BACKUP_DOCKER_RUN) bash -lc 'RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh' upload-cloud: backup-tools-build ## 백업 덤프 클라우드 업로드 - $(BACKUP_DOCKER_RUN) bash -lc 'WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh' + $(BACKUP_DOCKER_RUN) bash -lc '$(if $(WORKS_DRIVE_DRY_RUN),WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" )$(if $(WORKS_DRIVE_AUTH_MODE),WORKS_DRIVE_AUTH_MODE="$(WORKS_DRIVE_AUTH_MODE)" )BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh' + +works-drive-refresh-token: ## WORKS Drive OAuth refresh token 갱신 + WORKS_DRIVE_TOKEN_GRANT="$(WORKS_DRIVE_TOKEN_GRANT)" WORKS_DRIVE_AUTH_CODE="$(WORKS_DRIVE_AUTH_CODE)" WORKS_DRIVE_AUTH_CALLBACK_URL="$(WORKS_DRIVE_AUTH_CALLBACK_URL)" scripts/backup/refresh_works_drive_token.sh else dump: ## 백업 덤프 생성 DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh @@ -350,11 +356,17 @@ restore-plan: ## 복구 실행 계획 출력 RESTORE_INPUT="$(RESTORE_INPUT)" BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh upload-cloud: ## 백업 덤프 클라우드 업로드 - WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh + $(if $(WORKS_DRIVE_DRY_RUN),WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" )$(if $(WORKS_DRIVE_AUTH_MODE),WORKS_DRIVE_AUTH_MODE="$(WORKS_DRIVE_AUTH_MODE)" )BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh + +works-drive-refresh-token: ## WORKS Drive OAuth refresh token 갱신 + WORKS_DRIVE_TOKEN_GRANT="$(WORKS_DRIVE_TOKEN_GRANT)" WORKS_DRIVE_AUTH_CODE="$(WORKS_DRIVE_AUTH_CODE)" WORKS_DRIVE_AUTH_CALLBACK_URL="$(WORKS_DRIVE_AUTH_CALLBACK_URL)" scripts/backup/refresh_works_drive_token.sh endif dump-upload-cloud: dump upload-cloud ## 백업 덤프 생성 후 클라우드 업로드 +docker-image-upload-works: ## Docker 이미지를 WORKS Shared Drive archive로 업로드 + WORKS_DOCKER_COMMIT_CONTAINER="$(WORKS_DOCKER_COMMIT_CONTAINER)" DOCKER_IMAGE_REF="$(DOCKER_IMAGE_REF)" WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$(WORKS_DOCKER_IMAGE_ARCHIVE_DIR)" scripts/docker-image/upload_works_drive.sh + # --- 로컬 통합 코드 체크 --- PLAYWRIGHT_BROWSERS_PATH := $(HOME)/.cache/ms-playwright PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.superAdmin.test.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.superAdmin.test.tsx index 18149384..2458bfcf 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.superAdmin.test.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.superAdmin.test.tsx @@ -82,6 +82,7 @@ describe("TenantFineGrainedPermissionsPage Super Admin role tab", () => { id: "regular-user", name: "Regular User", email: "regular@example.com", + phone: "010-0000-0001", role: "user", status: "active", createdAt: "2026-06-17T00:00:00Z", @@ -126,4 +127,38 @@ describe("TenantFineGrainedPermissionsPage Super Admin role tab", () => { }), ); }); + + it("searches regular users and grants the Super Admin role from the target queue", async () => { + renderWithProviders( + + } + /> + , + ); + + fireEvent.click( + await screen.findByRole("tab", { name: "Super Admin 역할" }), + ); + + fireEvent.change( + await screen.findByPlaceholderText("UUID, 이름, 이메일, 전화번호 검색"), + { target: { value: "010-0000-0001" } }, + ); + fireEvent.click(screen.getByRole("button", { name: "부여 대상 추가" })); + + expect( + screen.getByTestId("super-admin-grant-target-regular-user"), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Super Admin 부여" })); + + await waitFor(() => + expect(bulkUpdateUsersMock).toHaveBeenCalledWith({ + userIds: ["regular-user"], + role: "super_admin", + }), + ); + }); }); diff --git a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx index 95e5f74a..25972609 100644 --- a/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantFineGrainedPermissionsPage.tsx @@ -67,6 +67,20 @@ function isBootstrapSuperAdminUser(user: UserSummary) { return user.metadata?.bootstrapSuperAdmin === true; } +function normalizeUserSearchText(value: string | undefined) { + return (value ?? "").trim().toLowerCase(); +} + +function matchesUserIdentitySearch(user: UserSummary, normalizedTerm: string) { + if (!normalizedTerm) { + return false; + } + + return [user.id, user.name, user.email, user.phone] + .map((value) => normalizeUserSearchText(value)) + .some((value) => value.includes(normalizedTerm)); +} + export function TenantFineGrainedPermissionsPage() { const queryClient = useQueryClient(); const navigate = useNavigate(); @@ -88,6 +102,10 @@ export function TenantFineGrainedPermissionsPage() { const [selectedSuperAdminUserIds, setSelectedSuperAdminUserIds] = useState< string[] >([]); + const [superAdminGrantSearch, setSuperAdminGrantSearch] = useState(""); + const [queuedSuperAdminGrantUsers, setQueuedSuperAdminGrantUsers] = useState< + UserSummary[] + >([]); const [assignmentSearchTerm, setAssignmentSearchTerm] = useState(""); const [assignmentSort, setAssignmentSort] = useState< "user" | "relation" | "level" @@ -149,6 +167,28 @@ export function TenantFineGrainedPermissionsPage() { }); }, [profile?.email, profile?.id, superAdminUsersQuery.data?.items]); + const queuedSuperAdminGrantUserIds = useMemo( + () => new Set(queuedSuperAdminGrantUsers.map((user) => user.id)), + [queuedSuperAdminGrantUsers], + ); + + const superAdminGrantSearchResults = useMemo(() => { + const normalizedTerm = normalizeUserSearchText(superAdminGrantSearch); + if (!normalizedTerm) { + return []; + } + + return (superAdminUsersQuery.data?.items ?? []) + .filter((user) => user.role !== "super_admin") + .filter((user) => !queuedSuperAdminGrantUserIds.has(user.id)) + .filter((user) => matchesUserIdentitySearch(user, normalizedTerm)) + .slice(0, 20); + }, [ + queuedSuperAdminGrantUserIds, + superAdminGrantSearch, + superAdminUsersQuery.data?.items, + ]); + const tenantRelationsQuery = useQuery({ queryKey: ["tenant-relations", targetTenantId], queryFn: () => fetchTenantRelations(targetTenantId), @@ -325,6 +365,10 @@ export function TenantFineGrainedPermissionsPage() { ), ); setSelectedSuperAdminUserIds([]); + if (variables.role === "super_admin") { + setQueuedSuperAdminGrantUsers([]); + setSuperAdminGrantSearch(""); + } }, onError: (err: AxiosError<{ error?: string }>) => { toast.error( @@ -342,6 +386,21 @@ export function TenantFineGrainedPermissionsPage() { ); }; + const queueSuperAdminGrantUser = (user: UserSummary) => { + setQueuedSuperAdminGrantUsers((current) => { + if (current.some((queuedUser) => queuedUser.id === user.id)) { + return current; + } + return [...current, user]; + }); + }; + + const removeQueuedSuperAdminGrantUser = (userId: string) => { + setQueuedSuperAdminGrantUsers((current) => + current.filter((user) => user.id !== userId), + ); + }; + const resolveBulkRelation = () => { if (bulkRelationMode === "page") { return bulkPageRelation; @@ -434,6 +493,22 @@ export function TenantFineGrainedPermissionsPage() { }); }; + const handleGrantSuperAdminRole = () => { + if (queuedSuperAdminGrantUsers.length === 0) { + toast.error( + t( + "msg.admin.permissions_direct.super_admin_grant_users_required", + "부여할 사용자를 하나 이상 추가하세요.", + ), + ); + return; + } + updateUserRoleMutation.mutate({ + userIds: queuedSuperAdminGrantUsers.map((user) => user.id), + role: "super_admin", + }); + }; + const queueTargetUsers = useCallback((users: UserSummary[]) => { setQueuedTargetUsers((current) => { const next = [...current]; @@ -1378,84 +1453,238 @@ export function TenantFineGrainedPermissionsPage() {

{t( "ui.admin.permissions_direct.super_admin_title", - "Super Admin 역할 회수", + "Super Admin 역할 관리", )}

{t( "msg.admin.permissions_direct.super_admin_description", - "현재 로그인한 관리자와 최초 관리자를 제외한 Super Admin 역할을 회수합니다.", + "사용자를 검색해 Super Admin 역할을 부여하고, 현재 로그인한 관리자와 최초 관리자를 제외한 기존 역할은 회수합니다.", )}

-
-
-

- {t( - "ui.admin.permissions_direct.super_admin_users", - "대상 사용자", - )} -

- - {selectedSuperAdminUserIds.length} - {t("ui.admin.permissions_direct.bulk_selected", "명 선택")} - -
-
- {superAdminUsersQuery.isFetching ? ( -
- {t("msg.common.loading", "불러오는 중입니다.")} -
- ) : revocableSuperAdminUsers.length === 0 ? ( -
+
+
+
+

{t( - "msg.admin.permissions_direct.no_users_found", - "등록된 사용자가 없습니다.", + "ui.admin.permissions_direct.super_admin_search", + "사용자 검색", + )} +

+ + {superAdminGrantSearchResults.length} + {t("ui.admin.permissions_direct.search_results", "건")} + +
+
+ + + setSuperAdminGrantSearch(event.target.value) + } + placeholder={t( + "ui.admin.permissions_direct.super_admin_search_placeholder", + "UUID, 이름, 이메일, 전화번호 검색", + )} + className="pl-9" + /> +
+
+ {superAdminUsersQuery.isFetching ? ( +
+ {t("msg.common.loading", "불러오는 중입니다.")} +
+ ) : !superAdminGrantSearch.trim() ? ( +
+ {t( + "msg.admin.permissions_direct.super_admin_search_empty", + "검색어를 입력하세요.", + )} +
+ ) : superAdminGrantSearchResults.length === 0 ? ( +
+ {t( + "msg.admin.permissions_direct.no_users_found", + "등록된 사용자가 없습니다.", + )} +
+ ) : ( + superAdminGrantSearchResults.map((user) => ( +
+ + + {user.name} + + + {user.email} + + + {user.phone || user.id} + + + +
+ )) + )} +
+
+ +
+
+
+

+ {t( + "ui.admin.permissions_direct.super_admin_grant_targets", + "부여 대상자", + )} +

+ + {queuedSuperAdminGrantUsers.length} + {t("ui.admin.permissions_direct.bulk_selected", "명 선택")} + +
+
+ {queuedSuperAdminGrantUsers.length === 0 ? ( +
+ {t( + "msg.admin.permissions_direct.super_admin_grant_queue_empty", + "부여할 사용자를 왼쪽 검색 결과에서 추가하세요.", + )} +
+ ) : ( + queuedSuperAdminGrantUsers.map((user) => ( +
+ + + {user.name} + + + {user.email} + + + +
+ )) )}
- ) : ( - revocableSuperAdminUsers.map((user) => ( -
-
+ {t( + "ui.admin.permissions_direct.super_admin_grant", + "Super Admin 부여", + )} + +
+
-
- +
+
+

+ {t( + "ui.admin.permissions_direct.super_admin_users", + "현재 Super Admin", + )} +

+ + {selectedSuperAdminUserIds.length} + {t("ui.admin.permissions_direct.bulk_selected", "명 선택")} + +
+
+ {superAdminUsersQuery.isFetching ? ( +
+ {t("msg.common.loading", "불러오는 중입니다.")} +
+ ) : revocableSuperAdminUsers.length === 0 ? ( +
+ {t( + "msg.admin.permissions_direct.no_users_found", + "등록된 사용자가 없습니다.", + )} +
+ ) : ( + revocableSuperAdminUsers.map((user) => ( + + )) + )} +
+
+ +
+
+
)} diff --git a/adminfront/tests/tenant-profile-performance-local.spec.ts b/adminfront/tests/tenant-profile-performance-local.spec.ts index 7c1e65ab..68a43cc4 100644 --- a/adminfront/tests/tenant-profile-performance-local.spec.ts +++ b/adminfront/tests/tenant-profile-performance-local.spec.ts @@ -6,9 +6,6 @@ import { expect, test, type Route } from "@playwright/test"; const targetTenantId = process.env.TENANT_PROFILE_PERF_TENANT_ID ?? "56cd0fd7-b62a-43c0-8db9-74a30468d7cb"; -const actualApiBaseUrl = - process.env.TENANT_PROFILE_PERF_API_BASE_URL ?? "http://localhost:5173/api"; -const normalizedActualApiBaseUrl = actualApiBaseUrl.replace(/\/$/, ""); const evidenceDir = path.resolve("e2e-evidence"); type ApiTiming = { @@ -52,6 +49,35 @@ async function fulfillFromLocalApi(route: Route, targetUrl?: string) { }); } +function resolveActualApiBaseUrl() { + const explicitApiBaseUrl = process.env.TENANT_PROFILE_PERF_API_BASE_URL; + if (explicitApiBaseUrl?.trim()) { + return explicitApiBaseUrl.trim().replace(/\/$/, ""); + } + + const proxyTarget = process.env.API_PROXY_TARGET; + if (proxyTarget?.trim()) { + return new URL("/api", `${proxyTarget.trim().replace(/\/$/, "")}/`) + .toString() + .replace(/\/$/, ""); + } + + return "http://127.0.0.1:5173/api"; +} + +async function canFetchJsonFromLocalApi(apiBaseUrl: string) { + const probeUrl = `${apiBaseUrl.replace(/\/$/, "")}/v1/user/me`; + try { + const response = await fetch(probeUrl, { + headers: { "x-test-role": "super_admin" }, + }); + const contentType = response.headers.get("content-type") ?? ""; + return contentType.toLowerCase().includes("application/json"); + } catch { + return false; + } +} + function percentile(values: number[], ratio: number) { const sorted = [...values].sort((left, right) => left - right); const index = Math.min( @@ -65,6 +91,13 @@ test.describe("Tenant profile local performance evidence", () => { test("loads org config fields through the local API within 500ms", async ({ page, }, testInfo) => { + const actualApiBaseUrl = resolveActualApiBaseUrl(); + test.skip( + !(await canFetchJsonFromLocalApi(actualApiBaseUrl)), + `Local API is not available at ${actualApiBaseUrl}; set TENANT_PROFILE_PERF_API_BASE_URL to run this evidence test.`, + ); + const normalizedActualApiBaseUrl = actualApiBaseUrl.replace(/\/$/, ""); + fs.mkdirSync(evidenceDir, { recursive: true }); await page.setViewportSize({ width: 1440, height: 900 }); diff --git a/config/traefik-compose.yml b/config/traefik-compose.yml new file mode 100644 index 00000000..46a0ba3e --- /dev/null +++ b/config/traefik-compose.yml @@ -0,0 +1,69 @@ +services: + traefik: + image: traefik:v3.7.5 + container_name: traefik + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + - ./letsencrypt:/letsencrypt + command: + - "--api.dashboard=true" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.network=traefik-public" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--entrypoints.web.http.redirections.entryPoint.to=websecure" + - "--entrypoints.web.http.redirections.entryPoint.scheme=https" + - "--certificatesresolvers.myresolver.acme.httpchallenge=true" + - "--certificatesresolvers.myresolver.acme.httpchallenge.entrypoint=web" + - "--certificatesresolvers.myresolver.acme.email=${TRAEFIK_ACME_EMAIL:-admin@hmac.kr}" + - "--certificatesresolvers.myresolver.acme.storage=/letsencrypt/acme.json" + labels: + - "traefik.enable=true" + - "traefik.http.routers.traefik-dashboard.rule=Host(`${TRAEFIK_DASHBOARD_HOST:-traefik.brsw.kr}`)" + - "traefik.http.routers.traefik-dashboard.service=api@internal" + - "traefik.http.routers.traefik-dashboard.entrypoints=websecure" + - "traefik.http.routers.traefik-dashboard.tls.certresolver=myresolver" + - "traefik.http.routers.traefik-dashboard.middlewares=auth-forward@docker" + networks: + - traefik-public + + forward-auth: + image: thomseddon/traefik-forward-auth:2.2.0 + container_name: forward-auth + restart: unless-stopped + environment: + - LOG_LEVEL=${TRAEFIK_FORWARD_AUTH_LOG_LEVEL:-info} + - DEFAULT_PROVIDER=generic-oauth + - PROVIDERS_GENERIC_OAUTH_AUTH_URL=${HYDRA_PUBLIC_URL:-https://app.brsw.kr/oidc}/oauth2/auth + - PROVIDERS_GENERIC_OAUTH_TOKEN_URL=${HYDRA_PUBLIC_URL:-https://app.brsw.kr/oidc}/oauth2/token + - PROVIDERS_GENERIC_OAUTH_USER_URL=${HYDRA_PUBLIC_URL:-https://app.brsw.kr/oidc}/userinfo + - PROVIDERS_GENERIC_OAUTH_CLIENT_ID=${TRAEFIK_FORWARD_AUTH_CLIENT_ID:-traefik-forward-auth} + - PROVIDERS_GENERIC_OAUTH_CLIENT_SECRET=${TRAEFIK_FORWARD_AUTH_CLIENT_SECRET} + - PROVIDERS_GENERIC_OAUTH_SCOPE=openid profile email + - SECRET=${TRAEFIK_FORWARD_AUTH_COOKIE_SECRET} + - AUTH_HOST=${TRAEFIK_FORWARD_AUTH_HOST:-app.brsw.kr} + - COOKIE_DOMAIN=${TRAEFIK_COOKIE_DOMAIN:-brsw.kr} + - URL_PATH=${TRAEFIK_FORWARD_AUTH_URL_PATH:-/_oauth} + - INSECURE_COOKIE=${TRAEFIK_FORWARD_AUTH_INSECURE_COOKIE:-false} + - LIFETIME=${TRAEFIK_FORWARD_AUTH_LIFETIME:-43200} + labels: + - "traefik.enable=true" + - "traefik.http.services.forward-auth.loadbalancer.server.port=4181" + - "traefik.http.middlewares.auth-forward.forwardauth.address=http://forward-auth:4181" + - "traefik.http.middlewares.auth-forward.forwardauth.trustForwardHeader=true" + - "traefik.http.middlewares.auth-forward.forwardauth.authResponseHeaders=X-Forwarded-User" + - "traefik.http.routers.forward-auth.rule=Host(`${TRAEFIK_FORWARD_AUTH_HOST:-app.brsw.kr}`) && PathPrefix(`${TRAEFIK_FORWARD_AUTH_URL_PATH:-/_oauth}`)" + - "traefik.http.routers.forward-auth.entrypoints=websecure" + - "traefik.http.routers.forward-auth.tls.certresolver=myresolver" + networks: + - traefik-public + +networks: + traefik-public: + external: true + name: traefik-public diff --git a/deploy/create-instance.sh b/deploy/create-instance.sh index e386e774..451f9729 100644 --- a/deploy/create-instance.sh +++ b/deploy/create-instance.sh @@ -1,7 +1,8 @@ #!/bin/bash +set -euo pipefail # ================================================================= -# Baron SSO 인스턴스 자동 생성 스크립트 (Full Infrastructure) +# Baron SSO 인스턴스 자동 생성 스크립트 (전체 인프라 포함) # ================================================================= if [ "$#" -ne 2 ]; then @@ -12,7 +13,8 @@ fi INSTANCE_NAME=$1 PORT_PREFIX=$2 BASE_DIR=$(cd $(dirname $0); pwd) -TARGET_DIR="${BASE_DIR}/../instances/${INSTANCE_NAME}" +REPO_ROOT=$(cd "${BASE_DIR}/.." && pwd) +TARGET_DIR="${TARGET_DIR:-${BASE_DIR}/../instances/${INSTANCE_NAME}}" echo "🚀 Creating instance: ${INSTANCE_NAME} (Port Prefix: ${PORT_PREFIX}xxx)" @@ -32,6 +34,22 @@ mkdir -p "${TARGET_DIR}/orgfront" # 2. .env 생성 및 변수 로드 sed "s/{{INSTANCE_NAME}}/${INSTANCE_NAME}/g; s/{{PORT_PREFIX}}/${PORT_PREFIX}/g" \ "${BASE_DIR}/templates/.env.template" > "${TARGET_DIR}/.env" +SOURCE_ROOT_ESCAPED=$(printf '%s\n' "$REPO_ROOT" | sed 's/[\/&]/\\&/g') +sed -i "s/^SOURCE_ROOT=.*/SOURCE_ROOT=${SOURCE_ROOT_ESCAPED}/" "${TARGET_DIR}/.env" + +# 생성된 compose 파일을 실행하기 전에 Traefik public network가 있어야 합니다. +TRAEFIK_PUBLIC_NETWORK=$(grep "^TRAEFIK_PUBLIC_NETWORK=" "${TARGET_DIR}/.env" | cut -d'=' -f2 | tr -d '\r') +TRAEFIK_PUBLIC_NETWORK=${TRAEFIK_PUBLIC_NETWORK:-traefik-public} +if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + if ! docker network inspect "$TRAEFIK_PUBLIC_NETWORK" >/dev/null 2>&1; then + echo "Creating Docker network ${TRAEFIK_PUBLIC_NETWORK}..." + docker network create "$TRAEFIK_PUBLIC_NETWORK" + else + echo "Docker network ${TRAEFIK_PUBLIC_NETWORK} already exists." + fi +else + echo "⚠️ docker command is unavailable or not accessible; skipping Traefik public network check." +fi # 포트 계산 (단순 치환) BACKEND_PORT="${PORT_PREFIX}000" @@ -44,21 +62,21 @@ ORGFRONT_DOMAIN="${INSTANCE_NAME}-org.${DOMAIN_SUFFIX}" # 3. Docker Compose & Config 복사 및 치환 cp "${BASE_DIR}/templates/docker-compose.yaml" "${TARGET_DIR}/" -# Gateway & UserFront Nginx +# Gateway 및 UserFront Nginx sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/gateway/nginx.conf" > "${TARGET_DIR}/gateway/nginx.conf" sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/userfront/nginx.conf" > "${TARGET_DIR}/userfront/nginx.conf" -# Oathkeeper Rules template +# Oathkeeper 규칙 템플릿 sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" "${BASE_DIR}/templates/ory/oathkeeper/rules.json" > "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" cp "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" "${TARGET_DIR}/ory/templates/oathkeeper/rules.stage.json" cp "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" "${TARGET_DIR}/ory/templates/oathkeeper/rules.prod.json" cp "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" "${TARGET_DIR}/ory/templates/oathkeeper/rules.active.json" -# Kratos Config template +# Kratos 설정 템플릿 sed "s/{{BACKEND_PORT}}/${BACKEND_PORT}/g; s/{{USERFRONT_PORT}}/${USERFRONT_PORT}/g" \ "${BASE_DIR}/templates/ory/kratos/kratos.yml.template" > "${TARGET_DIR}/ory/templates/kratos/kratos.yml.template" -# Vite Configs +# Vite 설정 sed "s/{{ADMINFRONT_DOMAIN}}/${ADMINFRONT_DOMAIN}/g; s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" \ "${BASE_DIR}/templates/adminfront/vite.config.ts" > "${TARGET_DIR}/adminfront/vite.config.ts" sed "s/{{DEVFRONT_DOMAIN}}/${DEVFRONT_DOMAIN}/g; s/{{BACKEND_PORT}}/${BACKEND_PORT}/g" \ @@ -88,7 +106,7 @@ ORY_CONFIG_OUTPUT_DIR="${TARGET_DIR}/config/.generated/ory" \ bash "${BASE_DIR}/../scripts/render_ory_config.sh" # 6. 마무리 -chmod +x "${TARGET_DIR}/.env" +chmod 600 "${TARGET_DIR}/.env" echo "--------------------------------------------------" echo "✅ Success! ALL files (Infra/Ory/Apps/FrontConfigs) are ready." diff --git a/deploy/deploy_guide.md b/deploy/deploy_guide.md index fc6d48da..7efb6ab8 100644 --- a/deploy/deploy_guide.md +++ b/deploy/deploy_guide.md @@ -43,12 +43,64 @@ cd instances/test3 docker compose up -d ``` +프로덕션처럼 고정 경로에 바로 생성해야 하면 `TARGET_DIR`를 지정합니다. + +```bash +cd deploy +TARGET_DIR=/home/user/prod.baron-sso ./create-instance.sh prod 30 +cd /home/user/prod.baron-sso +docker compose up -d +``` + ## 4. 도메인 및 URL 규칙 스크립트 실행 시 인스턴스 이름에 따라 다음 도메인이 자동으로 설정 파일에 주입됩니다. (`DOMAIN_SUFFIX`는 `.env`에서 수정 가능) - **SSO/UserFront**: `https://[이름]-sso.hmac.kr` - **AdminFront**: `https://[이름]-admin.hmac.kr` - **DevFront**: `https://[이름]-dev.hmac.kr` +프로덕션 도메인이 `app.brsw.kr`처럼 인스턴스 기본 규칙과 다르면 생성 후 `.env`에서 다음 값을 운영 도메인으로 고정합니다. + +```dotenv +USERFRONT_URL=https://app.brsw.kr +PUBLIC_HOST=app.brsw.kr +HYDRA_PUBLIC_URL=https://app.brsw.kr/oidc +VITE_OIDC_AUTHORITY=https://app.brsw.kr/oidc +``` + +## 4.1 Traefik 연동 + +생성된 `docker-compose.yaml`은 외부 진입 서비스에 Traefik docker provider label을 포함합니다. + +- `gateway`: `${PUBLIC_HOST}`로 라우팅하며 `app.brsw.kr` 같은 SSO 메인 도메인을 담당합니다. +- `adminfront`: `${ADMINFRONT_HOST}`로 라우팅합니다. +- `devfront`: `${DEVFRONT_HOST}`로 라우팅합니다. +- `orgfront`: `${ORGFRONT_HOST}`로 라우팅합니다. + +`deploy/create-instance.sh`는 `.env`의 `TRAEFIK_PUBLIC_NETWORK` 값을 읽어 기본 `traefik-public` external network가 없으면 생성합니다. Traefik은 별도 경로(`/home/user/traefik`)에서 먼저 실행되어 있어야 하며, Baron SSO compose는 public-facing 서비스만 이 external network에 붙입니다. + +## 4.2 프로덕션 이미지 버전 규칙 + +프로덕션 후보 이미지는 `.gitea/workflows/production_image_publish.yml`에서 `dev` 브랜치를 checkout해 빌드하고 공용 저장소에 push합니다. 운영자가 입력하는 값은 `version_prefix`이며 형식은 `vX.YYMM`입니다. + +최종 이미지 태그는 워크플로우가 checkout된 커밋의 4자리 short SHA를 마지막 자리로 붙여 자동 생성합니다. + +```text +v1.2606.ab12 +``` + +배포는 먼저 `.gitea/workflows/staging_image_deploy.yml`에서 최종 `image_tag`를 입력해 staging에 적용하고, 같은 `image_tag`를 `.gitea/workflows/production_image_deploy.yml`에 입력해 production에 적용합니다. 두 deploy workflow는 모두 `vX.YYMM.<커밋해시4자리>` 형식만 허용하고, 해당 태그의 이미지를 pull한 뒤 각 환경의 `APP_ENV`로 실행합니다. + +같은 이미지를 사용하기 위한 계약은 다음과 같습니다. + +```text +dev 브랜치 검증 + -> production_image_publish.yml에서 vX.YYMM. 이미지 build/push + -> staging_image_deploy.yml에서 같은 image_tag pull/up + -> production_image_deploy.yml에서 같은 image_tag pull/up +``` + +WORKS Drive archive를 켜면 publish workflow가 registry에 push한 이미지를 다시 pull한 뒤 `scripts/docker-image/upload_works_drive.sh`로 `docker-build-image///` 아래에 `image.tar.zst`, `image.tar.zst.sha256`, `manifest.json`을 저장합니다. 이 archive도 staging/production과 같은 `image_tag` 기준입니다. + ## 5. 주요 설정 파일 관리 - **Nginx (Gateway & UserFront)**: 각 인스턴스의 백엔드 포트를 자동으로 감지하여 리버스 프록시를 수행합니다. - **Ory Kratos**: `allowed_origins`, `allowed_return_urls`, `webhook` 주소 등이 인스턴스 포트에 맞게 자동 치환됩니다. diff --git a/deploy/templates/.env.template b/deploy/templates/.env.template index 822f3095..07b00921 100644 --- a/deploy/templates/.env.template +++ b/deploy/templates/.env.template @@ -2,6 +2,7 @@ INSTANCE_NAME={{INSTANCE_NAME}} COMPOSE_PROJECT_NAME=baron-sso-{{INSTANCE_NAME}} APP_ENV=production +SOURCE_ROOT=../.. # === [2] 포트 Prefix 설정 (예: 23 입력 시 23000, 23432 등 생성) === P={{PORT_PREFIX}} @@ -27,6 +28,13 @@ USERFRONT_URL=https://{{INSTANCE_NAME}}-sso.${DOMAIN_SUFFIX} ADMINFRONT_URL=https://{{INSTANCE_NAME}}-admin.${DOMAIN_SUFFIX} DEVFRONT_URL=https://{{INSTANCE_NAME}}-dev.${DOMAIN_SUFFIX} ORGFRONT_URL=https://{{INSTANCE_NAME}}-org.${DOMAIN_SUFFIX} +PUBLIC_HOST={{INSTANCE_NAME}}-sso.${DOMAIN_SUFFIX} +ADMINFRONT_HOST={{INSTANCE_NAME}}-admin.${DOMAIN_SUFFIX} +DEVFRONT_HOST={{INSTANCE_NAME}}-dev.${DOMAIN_SUFFIX} +ORGFRONT_HOST={{INSTANCE_NAME}}-org.${DOMAIN_SUFFIX} +TRAEFIK_PUBLIC_NETWORK=traefik-public +TRAEFIK_ENTRYPOINT=websecure +TRAEFIK_CERT_RESOLVER=myresolver # OIDC/Auth URL VITE_OIDC_AUTHORITY=${USERFRONT_URL}/oidc diff --git a/deploy/templates/devfront/vite.config.ts b/deploy/templates/devfront/vite.config.ts index f21fd889..bcaf1561 100644 --- a/deploy/templates/devfront/vite.config.ts +++ b/deploy/templates/devfront/vite.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ envPrefix: ["VITE_", "USERFRONT_"], server: { host: "127.0.0.1", + port: 5174, // 인스턴스별 도메인을 자동으로 허용 allowedHosts: ["{{DEVFRONT_DOMAIN}}", "localhost", "127.0.0.1"], proxy: { @@ -17,7 +18,7 @@ export default defineConfig({ }, preview: { host: "127.0.0.1", - port: 5173, + port: 5174, allowedHosts: ["{{DEVFRONT_DOMAIN}}", "localhost", "127.0.0.1"], proxy: { "/api": { diff --git a/deploy/templates/docker-compose.images.yaml b/deploy/templates/docker-compose.images.yaml new file mode 100644 index 00000000..ec3ab571 --- /dev/null +++ b/deploy/templates/docker-compose.images.yaml @@ -0,0 +1,392 @@ +name: ${COMPOSE_PROJECT_NAME} + +services: + # --- Infrastructure --- + postgres: + image: postgres:17-alpine + container_name: ${COMPOSE_PROJECT_NAME}_db + environment: + - POSTGRES_PASSWORD=${DB_PASSWORD} + ports: + - "${DB_PORT}:5432" + volumes: + - db_data:/var/lib/postgresql/data + networks: [app_net] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + + redis: + image: redis:7-alpine + container_name: ${COMPOSE_PROJECT_NAME}_redis + ports: + - "${REDIS_PORT}:6379" + networks: [app_net] + + clickhouse: + image: clickhouse/clickhouse-server:latest + container_name: ${COMPOSE_PROJECT_NAME}_clickhouse + environment: + - CLICKHOUSE_USER=baron + - CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD} + ports: + - "${CLICKHOUSE_PORT_HTTP}:8123" + - "${CLICKHOUSE_PORT_NATIVE}:9000" + volumes: + - clickhouse_data:/var/lib/clickhouse + networks: [app_net] + + # --- Ory Stack --- + postgres_ory: + image: postgres:${ORY_POSTGRES_TAG:-17-alpine} + container_name: ${COMPOSE_PROJECT_NAME}_ory_db + environment: + - POSTGRES_USER=${ORY_POSTGRES_USER:-ory} + - POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD} + - POSTGRES_DB=${ORY_POSTGRES_DB:-ory} + volumes: + - ory_db_data:/var/lib/postgresql/data + - ./ory/init-db:/docker-entrypoint-initdb.d:ro + networks: [app_net] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}"] + interval: 5s + + kratos-migrate: + image: oryd/kratos:${KRATOS_VERSION:-v26.2.0} + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/","${USERFRONT_URL}","${USERFRONT_URL}/","${USERFRONT_URL}/ko","${USERFRONT_URL}/ko/","${USERFRONT_URL}/en","${USERFRONT_URL}/en/","${USERFRONT_URL}/auth/callback","${USERFRONT_URL}/ko/auth/callback","${USERFRONT_URL}/en/auth/callback","${ADMINFRONT_URL}/auth/callback","${DEVFRONT_URL}/auth/callback","${ORGFRONT_URL}/auth/callback"]} + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login + volumes: + - ./config/.generated/ory/kratos:/etc/config/kratos:ro + command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes + networks: [app_net] + depends_on: + postgres_ory: { condition: service_healthy } + + kratos: + image: oryd/kratos:${KRATOS_VERSION:-v26.2.0} + container_name: ${COMPOSE_PROJECT_NAME}_kratos + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20 + - COOKIE_SECRET=${COOKIE_SECRET} + - KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL} + - KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} + - KRATOS_SELFSERVICE_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL} + - KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=${KRATOS_ALLOWED_RETURN_URLS_JSON:-["${KRATOS_UI_URL}","${KRATOS_UI_URL}/","${USERFRONT_URL}","${USERFRONT_URL}/","${USERFRONT_URL}/ko","${USERFRONT_URL}/ko/","${USERFRONT_URL}/en","${USERFRONT_URL}/en/","${USERFRONT_URL}/auth/callback","${USERFRONT_URL}/ko/auth/callback","${USERFRONT_URL}/en/auth/callback","${ADMINFRONT_URL}/auth/callback","${DEVFRONT_URL}/auth/callback","${ORGFRONT_URL}/auth/callback"]} + - KRATOS_SELFSERVICE_FLOWS_ERROR_UI_URL=${KRATOS_UI_URL}/error + - KRATOS_SELFSERVICE_FLOWS_SETTINGS_UI_URL=${KRATOS_UI_URL}/error?error=settings_disabled + - KRATOS_SELFSERVICE_FLOWS_RECOVERY_UI_URL=${KRATOS_UI_URL}/recovery + - KRATOS_SELFSERVICE_FLOWS_VERIFICATION_UI_URL=${KRATOS_UI_URL}/verification + - KRATOS_SELFSERVICE_FLOWS_LOGIN_UI_URL=${KRATOS_UI_URL}/login + - KRATOS_SELFSERVICE_FLOWS_REGISTRATION_UI_URL=${KRATOS_UI_URL}/registration + - KRATOS_SELFSERVICE_FLOWS_LOGOUT_AFTER_DEFAULT_BROWSER_RETURN_URL=${KRATOS_UI_URL}/login + volumes: + - ./config/.generated/ory/kratos:/etc/config/kratos:ro + command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier + networks: [app_net] + depends_on: + kratos-migrate: { condition: service_completed_successfully } + + hydra-migrate: + image: oryd/hydra:${HYDRA_VERSION:-v26.2.0} + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 + command: migrate sql up -e --yes + networks: [app_net] + depends_on: + postgres_ory: { condition: service_healthy } + + hydra: + image: oryd/hydra:${HYDRA_VERSION:-v26.2.0} + container_name: ${COMPOSE_PROJECT_NAME}_hydra + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20 + - URLS_SELF_ISSUER=${HYDRA_PUBLIC_URL} + - URLS_LOGIN=${HYDRA_LOGIN_URL:-${USERFRONT_URL}/login} + - URLS_CONSENT=${HYDRA_CONSENT_URL:-${USERFRONT_URL}/consent} + - URLS_ERROR=${HYDRA_ERROR_URL:-${USERFRONT_URL}/error} + - SECRETS_SYSTEM=${ORY_POSTGRES_PASSWORD} + volumes: + - ./config/.generated/ory/hydra:/etc/config/hydra:ro + command: serve -c /etc/config/hydra/hydra.yml all --dev + networks: [app_net] + depends_on: + hydra-migrate: { condition: service_completed_successfully } + + keto-migrate: + image: oryd/keto:${KETO_VERSION:-v26.2.0} + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 + volumes: + - ./config/.generated/ory/keto:/etc/config/keto:ro + command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"] + networks: [app_net] + depends_on: + postgres_ory: { condition: service_healthy } + + keto: + image: oryd/keto:${KETO_VERSION:-v26.2.0} + container_name: ${COMPOSE_PROJECT_NAME}_keto + env_file: .env + environment: + - DSN=postgres://${ORY_POSTGRES_USER:-ory}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20 + volumes: + - ./config/.generated/ory/keto:/etc/config/keto:ro + command: serve -c /etc/config/keto/keto.yml + networks: [app_net] + depends_on: + keto-migrate: { condition: service_completed_successfully } + + oathkeeper_logs_init: + image: alpine:latest + command: ["sh", "-c", "mkdir -p /var/log/oathkeeper && chown -R ${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001} /var/log/oathkeeper"] + volumes: + - oathkeeper_logs:/var/log/oathkeeper + networks: [app_net] + + oathkeeper: + image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v26.2.0} + container_name: ${COMPOSE_PROJECT_NAME}_oathkeeper + env_file: .env + user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}" + ports: + - "${OATHKEEPER_PROXY_PORT}:4455" + environment: + - APP_ENV=${APP_ENV:-production} + - LOG_LEVEL=debug + - OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect} + - OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret} + volumes: + - ./config/.generated/ory/oathkeeper:/etc/config/oathkeeper:ro + - oathkeeper_logs:/var/log/oathkeeper + entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"] + networks: [app_net] + depends_on: + oathkeeper_logs_init: { condition: service_completed_successfully } + kratos: { condition: service_started } + hydra: { condition: service_started } + + ory_stack_check: + image: alpine:latest + container_name: ${COMPOSE_PROJECT_NAME}_ory_stack_check + command: > + /bin/sh -c " + apk add --no-cache curl; + echo 'Wait for Ory services...'; + check_ready() { + name=\"$$1\"; + url=\"$$2\"; + max=\"$${ORY_STACK_CHECK_MAX_ATTEMPTS:-60}\"; + i=1; + while [ \"$$i\" -le \"$$max\" ]; do + if curl --connect-timeout 2 --max-time 3 -fsS \"$$url\" >/dev/null; then + echo \"Ory service ready: $$name\"; + return 0; + fi; + echo \"Waiting for Ory service: $$name ($$i/$$max)\"; + i=$$((i + 1)); + sleep 1; + done; + echo \"ERROR: Ory service not ready: $$name after $$max attempts ($$url)\" >&2; + echo \"ERROR: Check service logs: docker logs $${COMPOSE_PROJECT_NAME}_$$name\" >&2; + return 1; + }; + check_ready kratos http://kratos:4433/health/ready || exit 1; + check_ready hydra http://hydra:4444/health/ready || exit 1; + check_ready keto http://keto:4466/health/ready || exit 1; + echo 'Ory stack is ready.';" + depends_on: + - kratos + - hydra + - keto + networks: [app_net] + + init-rp: + image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0} + env_file: .env + entrypoint: ["/bin/sh", "-ec"] + command: + - | + upsert_client() { + ID=$$1 + shift + if hydra get oauth2-client --endpoint "$${HYDRA_ADMIN_URL:-http://hydra:4445}" "$$ID" >/dev/null 2>&1; then + hydra update oauth2-client --endpoint "$${HYDRA_ADMIN_URL:-http://hydra:4445}" "$$ID" "$$@" + else + hydra create oauth2-client --endpoint "$${HYDRA_ADMIN_URL:-http://hydra:4445}" --id "$$ID" "$$@" + fi + } + + upsert_client "adminfront" \ + --name "AdminFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${ADMINFRONT_CALLBACK_URLS:-$${ADMINFRONT_URL}/auth/callback}" + + upsert_client "devfront" \ + --name "DevFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${DEVFRONT_CALLBACK_URLS:-$${DEVFRONT_URL}/auth/callback}" + + upsert_client "orgfront" \ + --name "OrgFront" \ + --grant-type authorization_code,refresh_token \ + --response-type code \ + --scope openid,offline_access,profile,email \ + --token-endpoint-auth-method none \ + --redirect-uri "$${ORGFRONT_CALLBACK_URLS:-$${ORGFRONT_URL}/auth/callback}" + + upsert_client "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" \ + --secret "$${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}" \ + --grant-type client_credentials \ + --response-type token \ + --scope openid,offline_access,profile,email + depends_on: + ory_stack_check: { condition: service_completed_successfully } + networks: [app_net] + + # --- Application Services --- + backend: + image: ${BACKEND_IMAGE_NAME}:${IMAGE_TAG} + container_name: ${COMPOSE_PROJECT_NAME}_backend + env_file: .env + environment: + - PORT=${BACKEND_PORT} + - APP_ENV=${APP_ENV:-production} + - IDP_PROVIDER=${IDP_PROVIDER:-ory} + - USERFRONT_URL=${USERFRONT_URL} + - KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434} + - HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445} + - HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL} + - KETO_READ_URL=${KETO_READ_URL:-http://keto:4466} + - KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467} + - DB_HOST=postgres + - REDIS_ADDR=redis:6379 + - CLICKHOUSE_HOST=clickhouse + - SEED_TENANT_CSV_PATH=/app/seed-tenant.csv + ports: + - "${BACKEND_PORT}:${BACKEND_PORT}" + volumes: + - ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro + networks: [app_net] + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_started } + oathkeeper: { condition: service_started } + + gateway: + image: ${USERFRONT_IMAGE_NAME}:${IMAGE_TAG} + container_name: ${COMPOSE_PROJECT_NAME}_gateway + ports: + - "${USERFRONT_PORT}:80" + volumes: + - ./gateway/nginx.conf:/etc/nginx/nginx.conf:ro + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK:-traefik-public}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-gateway.rule=Host(`${PUBLIC_HOST}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-gateway.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-gateway.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-myresolver}" + - "traefik.http.services.${COMPOSE_PROJECT_NAME}-gateway.loadbalancer.server.port=80" + networks: + - app_net + - traefik_public + + adminfront: + image: ${ADMINFRONT_IMAGE_NAME}:${IMAGE_TAG} + container_name: ${COMPOSE_PROJECT_NAME}_adminfront + env_file: .env + environment: + - APP_ENV=${APP_ENV:-production} + - API_PROXY_TARGET=http://backend:${BACKEND_PORT} + ports: + - "${ADMINFRONT_PORT}:5173" + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK:-traefik-public}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-adminfront.rule=Host(`${ADMINFRONT_HOST}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-adminfront.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-adminfront.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-myresolver}" + - "traefik.http.services.${COMPOSE_PROJECT_NAME}-adminfront.loadbalancer.server.port=5173" + networks: + - app_net + - traefik_public + + devfront: + image: ${DEVFRONT_IMAGE_NAME}:${IMAGE_TAG} + container_name: ${COMPOSE_PROJECT_NAME}_devfront + env_file: .env + environment: + - APP_ENV=${APP_ENV:-production} + - API_PROXY_TARGET=http://backend:${BACKEND_PORT} + ports: + - "${DEVFRONT_PORT}:5174" + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK:-traefik-public}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-devfront.rule=Host(`${DEVFRONT_HOST}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-devfront.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-devfront.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-myresolver}" + - "traefik.http.services.${COMPOSE_PROJECT_NAME}-devfront.loadbalancer.server.port=5174" + networks: + - app_net + - traefik_public + + orgfront: + image: ${ORGFRONT_IMAGE_NAME}:${IMAGE_TAG} + container_name: ${COMPOSE_PROJECT_NAME}_orgfront + env_file: .env + environment: + - APP_ENV=${APP_ENV:-production} + - API_PROXY_TARGET=http://backend:${BACKEND_PORT} + - USERFRONT_URL=${USERFRONT_URL} + ports: + - "${ORGFRONT_PORT}:5175" + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK:-traefik-public}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-orgfront.rule=Host(`${ORGFRONT_HOST}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-orgfront.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-orgfront.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-myresolver}" + - "traefik.http.services.${COMPOSE_PROJECT_NAME}-orgfront.loadbalancer.server.port=5175" + networks: + - app_net + - traefik_public + +networks: + app_net: + name: ${COMPOSE_PROJECT_NAME}_net + traefik_public: + external: true + name: ${TRAEFIK_PUBLIC_NETWORK:-traefik-public} + +volumes: + db_data: + name: db_data_${INSTANCE_NAME} + ory_db_data: + name: ory_db_data_${INSTANCE_NAME} + clickhouse_data: + name: clickhouse_data_${INSTANCE_NAME} + oathkeeper_logs: + name: oathkeeper_logs_${INSTANCE_NAME} diff --git a/deploy/templates/docker-compose.yaml b/deploy/templates/docker-compose.yaml index b0ccc57e..fff7634f 100644 --- a/deploy/templates/docker-compose.yaml +++ b/deploy/templates/docker-compose.yaml @@ -288,7 +288,7 @@ services: ports: - "${BACKEND_PORT}:${BACKEND_PORT}" volumes: - - ../../adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro + - ${SOURCE_ROOT:-../..}/adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro networks: [app_net] depends_on: postgres: { condition: service_healthy } @@ -297,7 +297,7 @@ services: gateway: build: - context: ../.. + context: ${SOURCE_ROOT:-../..} dockerfile: ./userfront/Dockerfile target: production container_name: ${COMPOSE_PROJECT_NAME}_gateway @@ -305,11 +305,20 @@ services: - "${USERFRONT_PORT}:80" volumes: - ./gateway/nginx.conf:/etc/nginx/nginx.conf:ro - networks: [app_net] + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK:-traefik-public}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-gateway.rule=Host(`${PUBLIC_HOST}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-gateway.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-gateway.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-myresolver}" + - "traefik.http.services.${COMPOSE_PROJECT_NAME}-gateway.loadbalancer.server.port=80" + networks: + - app_net + - traefik_public adminfront: build: - context: ../.. + context: ${SOURCE_ROOT:-../..} dockerfile: ./adminfront/Dockerfile args: VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL} @@ -323,11 +332,20 @@ services: - API_PROXY_TARGET=http://backend:${BACKEND_PORT} ports: - "${ADMINFRONT_PORT}:5173" - networks: [app_net] + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK:-traefik-public}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-adminfront.rule=Host(`${ADMINFRONT_HOST}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-adminfront.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-adminfront.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-myresolver}" + - "traefik.http.services.${COMPOSE_PROJECT_NAME}-adminfront.loadbalancer.server.port=5173" + networks: + - app_net + - traefik_public devfront: build: - context: ../.. + context: ${SOURCE_ROOT:-../..} dockerfile: ./devfront/Dockerfile args: VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL} @@ -339,12 +357,21 @@ services: - APP_ENV=${APP_ENV:-production} - API_PROXY_TARGET=http://backend:${BACKEND_PORT} ports: - - "${DEVFRONT_PORT}:5173" - networks: [app_net] + - "${DEVFRONT_PORT}:5174" + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK:-traefik-public}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-devfront.rule=Host(`${DEVFRONT_HOST}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-devfront.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-devfront.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-myresolver}" + - "traefik.http.services.${COMPOSE_PROJECT_NAME}-devfront.loadbalancer.server.port=5174" + networks: + - app_net + - traefik_public orgfront: build: - context: ../.. + context: ${SOURCE_ROOT:-../..} dockerfile: ./orgfront/Dockerfile args: VITE_ORGFRONT_PUBLIC_URL: ${ORGFRONT_URL} @@ -358,11 +385,23 @@ services: - USERFRONT_URL=${USERFRONT_URL} ports: - "${ORGFRONT_PORT}:5175" - networks: [app_net] + labels: + - "traefik.enable=true" + - "traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK:-traefik-public}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-orgfront.rule=Host(`${ORGFRONT_HOST}`)" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-orgfront.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}" + - "traefik.http.routers.${COMPOSE_PROJECT_NAME}-orgfront.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-myresolver}" + - "traefik.http.services.${COMPOSE_PROJECT_NAME}-orgfront.loadbalancer.server.port=5175" + networks: + - app_net + - traefik_public networks: app_net: name: ${COMPOSE_PROJECT_NAME}_net + traefik_public: + external: true + name: ${TRAEFIK_PUBLIC_NETWORK:-traefik-public} volumes: db_data: diff --git a/devfront/Dockerfile b/devfront/Dockerfile index 9cfb2134..a7c5f24b 100644 --- a/devfront/Dockerfile +++ b/devfront/Dockerfile @@ -27,9 +27,9 @@ FROM deps AS dev WORKDIR /workspace/devfront ENV NODE_ENV=development -EXPOSE 5173 +EXPOSE 5174 -CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] +CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5174"] FROM deps AS build @@ -41,11 +41,11 @@ FROM node:24-alpine AS production WORKDIR /app ENV NODE_ENV=production ENV FRONTEND_DIST_DIR=/app/dist -ENV PORT=5173 +ENV PORT=5174 COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs COPY --from=build /workspace/devfront/dist ./dist -EXPOSE 5173 +EXPOSE 5174 CMD ["node", "./serve_frontend_prod.mjs"] diff --git a/devfront/scripts/runtime-mode.sh b/devfront/scripts/runtime-mode.sh index 30380c43..e4977602 100644 --- a/devfront/scripts/runtime-mode.sh +++ b/devfront/scripts/runtime-mode.sh @@ -136,8 +136,8 @@ ensure_frontend_dependencies if [ "$mode" = "production" ]; then echo "Running in production mode with Vite preview..." - exec sh -c "npm run build && npm run preview -- --host 0.0.0.0" + exec sh -c "npm run build && npm run preview -- --host 0.0.0.0 --port 5174" fi echo "Running in development mode..." -exec npm run dev -- --host 0.0.0.0 +exec npm run dev -- --host 0.0.0.0 --port 5174 diff --git a/devfront/vite.config.ts b/devfront/vite.config.ts index d4c89085..a4813692 100644 --- a/devfront/vite.config.ts +++ b/devfront/vite.config.ts @@ -26,6 +26,7 @@ export default defineConfig( }, server: { host: "127.0.0.1", + port: 5174, allowedHosts, proxy: { "/api": { @@ -36,7 +37,7 @@ export default defineConfig( }, preview: { host: "127.0.0.1", - port: 5173, + port: 5174, allowedHosts, proxy: { "/api": { diff --git a/docker-compose.yaml b/docker-compose.yaml index 59c7cd8f..11881d4f 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -94,7 +94,7 @@ services: VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY} VITE_OIDC_CLIENT_ID: devfront container_name: baron_devfront - command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"] + command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5174"] working_dir: /workspace/devfront env_file: - .env @@ -105,7 +105,7 @@ services: - VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false} - DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true} ports: - - "${DEVFRONT_PORT:-5174}:5173" + - "${DEVFRONT_PORT:-5174}:5174" volumes: - ./devfront:/workspace/devfront - ./common:/common diff --git a/docker/docker-compose.staging.template.yaml b/docker/docker-compose.staging.template.yaml index 550aa4af..4eb8c4cc 100644 --- a/docker/docker-compose.staging.template.yaml +++ b/docker/docker-compose.staging.template.yaml @@ -56,7 +56,7 @@ services: - APP_ENV=stage - API_PROXY_TARGET=http://baron_backend:3000 ports: - - "${DEVFRONT_PORT:-5174}:5173" + - "${DEVFRONT_PORT:-5174}:5174" networks: - baron_net diff --git a/docker/docker-compose.template.yaml b/docker/docker-compose.template.yaml index c9826ca9..39e742d7 100644 --- a/docker/docker-compose.template.yaml +++ b/docker/docker-compose.template.yaml @@ -84,7 +84,7 @@ services: - APP_ENV=production - API_PROXY_TARGET=http://baron_backend:${BACKEND_PORT:-3000} ports: - - "${DEVFRONT_PORT:-5174}:5173" + - "${DEVFRONT_PORT:-5174}:5174" depends_on: backend: condition: service_healthy diff --git a/docker/staging_pull_compose.template.yaml b/docker/staging_pull_compose.template.yaml index 977aa841..e65c50c5 100644 --- a/docker/staging_pull_compose.template.yaml +++ b/docker/staging_pull_compose.template.yaml @@ -464,11 +464,11 @@ services: - APP_ENV=${APP_ENV:-stage} - API_PROXY_TARGET=http://baron_backend:3000 ports: - - "${DEVFRONT_PORT:-5174}:5173" + - "${DEVFRONT_PORT:-5174}:5174" networks: - baron_net healthcheck: - test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:5173/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] + test: ["CMD-SHELL", "node -e \"fetch('http://127.0.0.1:5174/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""] interval: 10s timeout: 5s retries: 12 diff --git a/docs/backup-restore-design.md b/docs/backup-restore-design.md index b632c947..f2ebb480 100644 --- a/docs/backup-restore-design.md +++ b/docs/backup-restore-design.md @@ -287,9 +287,10 @@ Upload flow: 2. `dump.sh`가 `reports/backup-report.md`를 생성한다. report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록된다. 3. `upload_cloud.sh`가 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 압축한다. 4. Drive API용 access token을 확인한다. - - 우선순위: `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD` - - fallback 1: `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 Drive 앱 access token 갱신 - - fallback 2: `WORKS_DRIVE_OAUTH_*` 서비스 계정 JWT 토큰 발급 + - `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`는 항상 최우선이다. + - `WORKS_DRIVE_AUTH_MODE=auto` 기본값은 service account credentials가 완비되어 있으면 `WORKS_DRIVE_OAUTH_*` 서비스 계정 JWT 토큰 발급을 먼저 사용하고, 없으면 `WORKS_DRIVE_OAUTH_REFRESH_TOKEN` 갱신으로 fallback한다. + - `WORKS_DRIVE_AUTH_MODE=service-account`는 service account JWT 토큰 발급을 강제한다. + - `WORKS_DRIVE_AUTH_MODE=refresh-token`은 service account 설정이 있어도 refresh token 갱신을 강제한다. 5. WORKS Drive upload URL 생성 API를 호출한다. 6. 발급받은 upload URL에 multipart `Filedata`로 `.tar.zst` archive를 업로드한다. 7. `WORKS_DRIVE_UPLOAD_REPORTS=true`이면 대상 폴더 아래 `WORKS_DRIVE_REPORT_FOLDER_NAME` 하위 폴더를 찾거나 생성한 뒤 `reports/*.md`만 업로드한다. @@ -312,8 +313,13 @@ Upload flow: - Drive API는 `file` scope가 필요하다. - `WORKS_DRIVE_PARENT_FILE_ID`는 폴더 이름이나 경로가 아니라 WORKS Drive API가 반환하는 폴더 `fileId`여야 한다. - 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 백업 업로드용 `WORKS_DRIVE_OAUTH_*`는 용도가 다른 앱/키로 분리한다. -- 운영 기본 경로는 Drive용 access token을 명시 주입하거나 `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 갱신하는 방식이다. -- 서비스 계정 JWT fallback은 Drive 업로드 앱 정책에서 Drive scope 위임이 허용된 경우에만 성공한다. +- 운영 기본 경로는 Drive용 access token을 명시 주입하거나 service account JWT를 사용하는 방식이다. +- refresh token을 재발급해 사용하는 경우 `make works-drive-refresh-token`을 사용한다. WORKS OAuth refresh token은 Authorization Code Flow에서 발급되며, WORKS Token 설정의 Refresh Token Rotation 상태에 따라 갱신 응답에 새 refresh token이 포함될 수 있다. + - 기존 refresh token이 아직 유효하면 `make works-drive-refresh-token WORKS_DRIVE_TOKEN_GRANT=refresh-token`로 `.env`의 `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`을 자동 갱신한다. + - 기존 refresh token이 폐기되어 401이 발생하면 `WORKS_DRIVE_TOKEN_GRANT=print-authorize-url scripts/backup/refresh_works_drive_token.sh`로 출력된 URL을 브라우저에서 승인한 뒤, callback의 `code`를 `make works-drive-refresh-token WORKS_DRIVE_TOKEN_GRANT=authorization-code WORKS_DRIVE_AUTH_CODE=`에 전달한다. + - callback URL 전체를 복사할 수 있으면 `WORKS_DRIVE_AUTH_CALLBACK_URL=`을 사용해도 된다. + - 토큰 갱신 도구는 짧게 만료되는 access token을 `.env`에 저장하지 않고 refresh token과 `WORKS_DRIVE_AUTH_MODE=refresh-token`만 갱신한다. +- 서비스 계정 JWT는 Drive 업로드 앱 정책에서 Drive scope 위임이 허용되고 Client ID, Service Account, Private Key가 같은 앱에서 발급된 조합일 때만 성공한다. - 파일 크기가 WORKS Drive 단일 파일 제한에 걸릴 수 있으면 `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` 또는 `WORKS_DRIVE_FORCE_SPLIT=true`로 split part 업로드를 사용한다. - Markdown report 업로드 기본 폴더명은 `reports`이며 `WORKS_DRIVE_REPORT_FOLDER_NAME`으로 바꿀 수 있다. - 외부 업로드 성공은 복구 가능성을 보장하지 않는다. 업로드 후에도 `make dump-verify BACKUP=...`와 restore rehearsal을 별도로 수행해야 한다. diff --git a/docs/traefik-production-rp-bootstrap-design.md b/docs/traefik-production-rp-bootstrap-design.md new file mode 100644 index 00000000..5b5835ce --- /dev/null +++ b/docs/traefik-production-rp-bootstrap-design.md @@ -0,0 +1,190 @@ +# Traefik Production RP Bootstrap Design + +## Context + +프로덕션 배포환경에서는 Baron SSO 앞단에 Traefik이 reverse proxy로 먼저 떠 있고, Traefik dashboard와 보호 대상 라우트도 Baron SSO 인증을 사용해야 한다. + +현재 `config/traefik-compose.yml`은 Traefik과 `traefik-forward-auth`를 사전 구동하는 형태지만 다음 보완이 필요하다. + +- `CLIENT_ID`, `CLIENT_SECRET`, `SECRET`가 파일에 하드코딩되어 있다. +- OIDC endpoint가 Baron/Ory Hydra가 아니라 Keycloak style path를 가리킨다. +- `traefik-public` external network가 선언만 되어 있고 서비스에 연결되어 있지 않다. +- production boot flow에서 Traefik forward-auth RP가 Hydra에 자동 등록되지 않는다. + +관련 이슈: #1221 + +## Policy Alignment + +- OAuth2/OIDC client SoT는 Ory Hydra다. +- client secret 원문은 Git에 커밋하지 않고 `.env`, 배포 host의 secret file, Gitea Actions secret, 또는 운영 secret store에서 주입한다. +- Kratos는 identity SoT, Keto는 authorization relation SoT로 유지한다. +- Traefik과 forward-auth가 신뢰할 수 있는 last-hop proxy가 되며, 애플리케이션은 임의 외부 요청의 identity header를 신뢰하지 않는다. +- Wiki가 사용 중이므로 실제 Wiki 업데이트 전 검토본은 `docs/` 문서로 둔다. + +## Target Architecture + +```mermaid +flowchart TD + User[Browser] --> Traefik[Traefik edge proxy] + Traefik -->|ForwardAuth| TFA[traefik-forward-auth] + TFA -->|OIDC authorize/token/userinfo| Hydra[Ory Hydra public endpoint] + Hydra --> UserFront[Baron UserFront login/consent] + UserFront --> Backend[Baron Backend] + Backend --> Kratos[Ory Kratos] + Backend --> Keto[Ory Keto] + Traefik --> Dashboard[api@internal dashboard] + Traefik --> BaronRoute[Baron app routes] +``` + +`traefik-forward-auth`는 Hydra confidential client로 등록한다. SPA용 `adminfront`, `devfront`, `orgfront`와 달리 server-side middleware이므로 `client_secret` 기반 client로 다룬다. + +## Configuration Contract + +운영 환경 변수는 다음 이름을 기준으로 둔다. + +| Key | Required | Example | Note | +|---|---:|---|---| +| `TRAEFIK_DASHBOARD_HOST` | yes | `traefik.brsw.kr` | Traefik dashboard host | +| `TRAEFIK_FORWARD_AUTH_HOST` | yes | `auth.brsw.kr` | forward-auth callback host | +| `TRAEFIK_FORWARD_AUTH_CLIENT_ID` | yes | `traefik-forward-auth` | Hydra client id | +| `TRAEFIK_FORWARD_AUTH_CLIENT_SECRET` | yes in production | secret value | Git에 저장하지 않는다 | +| `TRAEFIK_FORWARD_AUTH_COOKIE_SECRET` | yes in production | 32+ byte random | forward-auth cookie signing secret | +| `TRAEFIK_FORWARD_AUTH_CALLBACK_URLS` | yes | `https://auth.brsw.kr/_oauth` | comma-separated | +| `HYDRA_PUBLIC_URL` | yes | `https://app.brsw.kr/oidc` | Baron/Ory public issuer base | + +`config/traefik-compose.yml`은 위 값을 직접 박지 않고 `${...}` placeholder만 사용한다. 여기서 금지하는 것은 "운영 secret 원문을 코드/문서/compose 파일에 커밋하는 것"이다. 배포 이후에는 발급된 client id/secret/cookie secret을 운영 설정 또는 저장소 secret에 고정해 재사용해야 한다. + +## Compose Design + +`config/traefik-compose.yml`의 방향은 다음과 같다. + +- `traefik`와 `forward-auth` 모두 `traefik-public` external network에 붙인다. +- `forward-auth` service에는 `traefik.http.services.forward-auth.loadbalancer.server.port=4181` label을 명시한다. +- dashboard router에는 `auth-forward@docker` middleware를 적용한다. +- forward-auth provider endpoint는 `HYDRA_PUBLIC_URL` 기반 `generic-oauth` 설정으로 산출한다. Traefik이 Baron/Ory보다 먼저 떠야 하는 bootstrap 순서에서는 OIDC discovery가 시작 시점에 실패할 수 있으므로 명시 endpoint 방식을 사용한다. + - auth: `${HYDRA_PUBLIC_URL}/oauth2/auth` + - token: `${HYDRA_PUBLIC_URL}/oauth2/token` + - userinfo: `${HYDRA_PUBLIC_URL}/userinfo` +- `INSECURE_COOKIE=false`를 production 기본값으로 둔다. +- `CLIENT_SECRET`과 cookie `SECRET`는 운영 secret 주입만 허용한다. + +Baron app compose 쪽에서도 production-facing 진입 서비스는 `traefik-public`에 붙어야 한다. 기존 정책상 외부 진입은 gateway/Oathkeeper 계층으로 수렴하는 것이 맞으므로, Traefik이 직접 backend admin endpoint로 붙는 구조는 피한다. + +## Hydra Client Bootstrap Design + +현재 `compose.ory.yaml`의 `init-rp`는 Hydra 준비 후 기본 RP를 등록한다. Traefik forward-auth RP도 같은 부팅 경로에 넣되, production mode에서만 활성화한다. + +권장 조건: + +```sh +APP_ENV in production|prod +TRAEFIK_FORWARD_AUTH_ENABLED=true +``` + +등록 payload: + +```json +{ + "client_id": "traefik-forward-auth", + "client_name": "Traefik Forward Auth", + "client_secret": "", + "grant_types": ["authorization_code", "refresh_token"], + "response_types": ["code"], + "scope": "openid offline_access profile email", + "redirect_uris": ["https://auth.brsw.kr/_oauth"], + "token_endpoint_auth_method": "client_secret_basic", + "metadata": { + "managed_by": "baron-sso-boot", + "system_client": true, + "purpose": "traefik-forward-auth", + "status": "active" + } +} +``` + +구현은 delete/create보다 idempotent upsert가 안전하다. 최초 부팅 시에는 값이 없으면 생성값을 발급할 수 있지만, RP가 한번 등록된 뒤에는 같은 `client_id`, `client_secret`, callback URI를 운영 설정으로 고정해야 한다. 재배포마다 secret이 바뀌면 forward-auth cookie/session과 Hydra client 인증이 깨질 수 있으므로 rotation은 별도 운영 작업으로만 수행한다. + +1. `hydra get oauth2-client `로 존재 여부 확인 +2. 없으면 env/secret store 값을 사용해 `hydra create oauth2-client` +3. 없고 초기 생성 모드가 명시되어 있으면 random secret을 생성해 운영자가 저장할 수 있게 출력 또는 secret file에 기록 +4. 있으면 `hydra update oauth2-client`로 redirect URI, scope, metadata 같은 비밀값이 아닌 설정을 동기화 +5. client secret은 기본적으로 덮어쓰지 않고, rotation flag가 있을 때만 갱신 +6. secret mismatch나 필수 redirect URI 누락 시 실패 + +기본 RP 등록 shell block이 길어지고 있으므로, 구현 단계에서는 `scripts/register_hydra_clients.sh` 같은 별도 boot script로 분리하는 편이 유지보수에 유리하다. + +## Generated Auth Config + +`scripts/auth_config.sh`는 다음을 확장한다. + +- `TRAEFIK_FORWARD_AUTH_CALLBACK_URLS` parsing +- production mode에서 Traefik client id/secret/cookie secret 필수 검증 +- generated `config/.generated/auth-config.env`에 Traefik callback CSV 포함 +- `KRATOS_ALLOWED_RETURN_URLS_JSON`에 forward-auth callback URL 포함 +- `verify` mode에서 runtime Hydra client에 Traefik callback이 들어 있는지 확인 + +운영 secret 보관 위치는 두 가지를 허용한다. + +- 배포 host 기준: `.env` 또는 Docker secret/secret file로 주입 +- 저장소 기준: Gitea Actions secret에 저장하고 배포 workflow에서 `.env` 또는 secret file로 렌더링 + +두 경우 모두 Git tracked 파일에는 실제 secret 값을 남기지 않는다. + +이렇게 하면 Ory 렌더링과 Hydra RP 등록이 같은 auth config contract를 공유한다. + +## Test Plan + +구현 전에 RED test를 먼저 추가한다. + +1. `test/traefik_forward_auth_config_policy_test.sh` + - `config/traefik-compose.yml`에 literal `CLIENT_SECRET=` 또는 `SECRET=` 값이 있으면 실패 + - `traefik`와 `forward-auth`가 `traefik-public` network에 붙지 않으면 실패 + - `PROVIDER_GENERIC_*_URL`이 Keycloak path를 사용하면 실패 + - dashboard router에 `auth-forward` middleware가 없으면 실패 + +2. `test/auth_config_traefik_rp_policy_test.sh` + - `APP_ENV=production`에서 Traefik client secret이 없으면 `scripts/auth_config.sh validate` 실패 + - callback URL이 `/_oauth` 형태가 아니거나 http URL이면 실패 + - generated env에 `TRAEFIK_FORWARD_AUTH_CALLBACK_URLS`가 없으면 실패 + +3. `test/compose_ory_traefik_rp_bootstrap_policy_test.sh` + - `init-rp` 또는 분리된 boot script가 Traefik RP를 등록하지 않으면 실패 + - boot flow가 delete/create only 방식이면 실패하고 upsert 방식을 요구 + +4. live verification + - `hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" "$TRAEFIK_FORWARD_AUTH_CLIENT_ID"` + - dashboard 접근 시 Hydra authorize redirect 발생 + - callback URL이 registered redirect URI와 정확히 일치 + - `docker network inspect traefik-public`에서 `traefik`, `forward-auth`, production entry service 연결 확인 + +## Rollout Sequence + +1. `.env.sample`에 Traefik forward-auth 변수를 placeholder로 추가한다. +2. RED policy tests를 추가하고 실패를 확인한다. +3. `config/traefik-compose.yml`을 env-driven Baron/Ory 설정으로 교체한다. +4. `scripts/auth_config.sh`와 Hydra RP bootstrap script를 확장한다. +5. `compose.ory.yaml`의 `init-rp`가 production에서 Traefik RP upsert를 실행하도록 연결한다. +6. Baron SSO production deployment template의 public-facing services에 Traefik labels와 `traefik-public` external network를 연결한다. +7. `make validate-auth-config`, 관련 shell policy tests, compose config 검증을 통과시킨다. +8. 운영 환경에서 live verification을 수행한다. + +## Baron SSO Deployment Labels + +`deploy/templates/docker-compose.yaml`에서 외부 진입 서비스는 다음 Traefik router를 갖는다. + +| Service | Host variable | Internal port | Purpose | +|---|---|---:|---| +| `gateway` | `PUBLIC_HOST` | 80 | SSO main app, `/api`, `/auth`, `/oidc` | +| `adminfront` | `ADMINFRONT_HOST` | 5173 | Admin console | +| `devfront` | `DEVFRONT_HOST` | 5173 | Developer/RP console | +| `orgfront` | `ORGFRONT_HOST` | 5175 | Organization console | + +각 public-facing service는 내부 `app_net`과 external `traefik_public`에 동시에 연결한다. `traefik_public`의 실제 Docker network name은 `.env`의 `TRAEFIK_PUBLIC_NETWORK`로 관리하며 기본값은 `traefik-public`이다. + +`deploy/create-instance.sh`는 `TARGET_DIR=/home/user/prod.baron-sso`처럼 레포 밖 고정 경로에 생성할 수 있고, 이 경우에도 compose build context가 깨지지 않도록 `.env`의 `SOURCE_ROOT`를 실행 중인 레포 루트 절대 경로로 채운다. + +## Open Decisions + +- `TRAEFIK_FORWARD_AUTH_HOST`를 `auth.brsw.kr`로 분리할지, `app.brsw.kr/_oauth` 같은 동일 host callback으로 둘지 결정이 필요하다. +- `External RP Ory IAM Foundation` 마일스톤은 현재 Due Date가 비어 있다. 구현 착수 전에 목표 Due Date를 정하는 것이 좋다. +- `traefik-forward-auth`를 계속 사용할지, Baron Backend/Oathkeeper에 first-party forward-auth endpoint를 만들지는 별도 장기 개선안으로 남긴다. 단기 목표는 현재 compose 구조를 안전하게 Baron/Ory에 맞추는 것이다. diff --git a/docs/works-drive-docker-image-archive.md b/docs/works-drive-docker-image-archive.md new file mode 100644 index 00000000..052ebde7 --- /dev/null +++ b/docs/works-drive-docker-image-archive.md @@ -0,0 +1,121 @@ +# WORKS Drive Docker Image Archive + +## 목적 + +WORKS Drive는 Docker Registry HTTP API v2 backend로 직접 사용하지 않는다. 대신 프로덕션 배포용 Docker 이미지를 `docker save` 결과물로 내보내고, zstd 압축 archive와 검증 파일을 WORKS Shared Drive에 보관하는 CLI 기반 보조 저장소로 사용한다. + +이 방식은 다음 상황을 목표로 한다. + +- Harbor 또는 공용 Registry 장애 시 수동 복구용 이미지 보관 +- 작은 규모의 프로덕션 배포 이미지 이관 +- `docker load` 기반 오프라인 배포 + +## 저장 구조 + +기본 최상위 디렉터리는 다음 환경 변수로 지정한다. + +```dotenv +WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR=docker-build-image +``` + +이미지는 WORKS Shared Drive에서 다음 구조로 저장한다. + +```text +docker-build-image/// + image.tar.zst + image.tar.zst.sha256 + manifest.json +``` + +예시: + +```text +docker-build-image/baron_sso/backend/v1.2606.ab12/ + image.tar.zst + image.tar.zst.sha256 + manifest.json +``` + +Registry hostname은 저장 경로에서 제외한다. 예를 들어 `registry.example/baron_sso/backend:v1.2606.ab12`는 `baron_sso/backend/v1.2606.ab12` 아래에 저장한다. + +## Manifest + +`manifest.json`에는 다음 정보를 기록한다. + +- archive format: `docker-save-zstd` +- 원본 `image_ref` +- repository path +- tag +- Docker image id +- Git commit +- archive 파일명, 크기, sha256 +- WORKS Drive remote path +- 복원 명령 예시 + +복원은 다음 흐름으로 처리한다. + +```bash +sha256sum -c image.tar.zst.sha256 +zstd -d -c image.tar.zst | docker load +``` + +## 업로드 CLI + +로컬 컨테이너를 먼저 이미지로 commit한 뒤 업로드하려면 다음처럼 실행한다. + +```bash +WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR=docker-build-image \ +WORKS_DOCKER_COMMIT_CONTAINER=baron_backend \ +DOCKER_IMAGE_REF=registry.example/baron_sso/backend:v1.2606.ab12 \ +scripts/docker-image/upload_works_drive.sh +``` + +이미지가 이미 로컬에 있으면 `WORKS_DOCKER_COMMIT_CONTAINER`를 생략한다. + +```bash +DOCKER_IMAGE_REF=registry.example/baron_sso/backend:v1.2606.ab12 \ +scripts/docker-image/upload_works_drive.sh +``` + +실제 업로드에는 기존 백업 업로드와 같은 WORKS Drive 인증 변수를 사용한다. + +- `WORKS_DRIVE_TARGET=sharedrive` +- `WORKS_DRIVE_SHARED_DRIVE_ID` 또는 `WORKS_SHAREDRIVE_ID` +- 선택: `WORKS_DRIVE_PARENT_FILE_ID` +- `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 또는 서비스 계정 OAuth 변수 + +업로드 전 packaging만 확인하려면 다음을 사용한다. + +```bash +WORKS_DRIVE_DRY_RUN=true \ +DOCKER_IMAGE_REF=registry.example/baron_sso/backend:v1.2606.ab12 \ +scripts/docker-image/upload_works_drive.sh +``` + +smoke 검증에는 Alpine 계열보다 운영 환경과 libc/패키지 계열 차이가 적은 Debian slim 계열을 사용한다. + +```bash +docker create --name baron-works-image-smoke debian:trixie-slim \ + sh -c 'printf works-drive-docker-image-smoke >/works-smoke.txt' +docker start -a baron-works-image-smoke +WORKS_DOCKER_COMMIT_CONTAINER=baron-works-image-smoke \ +DOCKER_IMAGE_REF=registry.example/baron_sso/works-smoke:works-test-ab12 \ +scripts/docker-image/upload_works_drive.sh +``` + +## Staging/Production 계약 + +Action에서 `dev` 브랜치를 checkout한 뒤 한 번만 이미지를 빌드하고 immutable `image_tag`를 계산한다. staging과 production은 같은 image_tag를 입력받아 같은 registry image를 pull한다. + +```text +dev branch -> publish image tag vX.YYMM. -> staging deploy -> production deploy +``` + +WORKS Drive archive도 Action에서 push된 이미지를 다시 pull한 뒤 `docker save`로 만든다. 따라서 WORKS archive, staging, production은 모두 같은 registry image tag를 기준으로 한다. + +## 제한 + +- 이 구조는 `docker push`/`docker pull`과 호환되는 Registry backend가 아니다. +- layer deduplication이 없으므로 같은 기반 이미지가 반복 저장된다. +- 배포 전에는 반드시 `image.tar.zst.sha256` 검증 후 `docker load`를 수행해야 한다. +- tag 없는 image ref와 digest-only image ref는 지원하지 않는다. diff --git a/scripts/backup/refresh_works_drive_token.sh b/scripts/backup/refresh_works_drive_token.sh new file mode 100755 index 00000000..130b2057 --- /dev/null +++ b/scripts/backup/refresh_works_drive_token.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$script_dir/lib/common.sh" + +repo_root="$(backup_repo_root)" +env_file="${WORKS_DRIVE_ENV_FILE:-$repo_root/.env}" + +if [[ -f "$env_file" ]]; then + set -a + # shellcheck source=/dev/null + source "$env_file" + set +a +fi + +token_grant="${WORKS_DRIVE_TOKEN_GRANT:-refresh-token}" +token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}" +authorize_url="${WORKS_DRIVE_OAUTH_AUTHORIZE_URL:-https://auth.worksmobile.com/oauth2/v2.0/authorize}" +client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" +client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" +redirect_uri="${WORKS_DRIVE_OAUTH_REDIRECT_URI:-}" +scope="${WORKS_DRIVE_OAUTH_SCOPE:-file}" +curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}" +set_auth_mode="${WORKS_DRIVE_TOKEN_SET_AUTH_MODE:-true}" + +backup_require_command jq + +urlencode() { + jq -nr --arg value "$1" '$value|@uri' +} + +redact_for_log() { + sed -E 's/("(access_token|refresh_token|client_secret)"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"REDACTED"/Ig' +} + +split_curl_response() { + local response="$1" + local __body_var="$2" + local __status_var="$3" + local status + local body + + status="$(tail -n 1 <<<"$response")" + if [[ "$status" =~ ^[0-9][0-9][0-9]$ ]]; then + body="$(sed '$d' <<<"$response")" + else + status="200" + body="$response" + fi + + printf -v "$__body_var" '%s' "$body" + printf -v "$__status_var" '%s' "$status" +} + +require_oauth_client() { + [[ -n "$client_id" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_ID is required." + [[ -n "$client_secret" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_SECRET is required." +} + +extract_code_from_callback_url() { + local raw_url="$1" + local query + local param + local code="" + local decoded + + query="${raw_url#*\?}" + query="${query%%#*}" + + IFS='&' read -r -a params <<<"$query" + for param in "${params[@]}"; do + if [[ "$param" == code=* ]]; then + code="${param#code=}" + break + fi + done + + decoded="${code//+/ }" + printf '%b' "${decoded//%/\\x}" +} + +write_env_value() { + local key="$1" + local value="$2" + local tmp_file + local env_uid="" + local env_gid="" + local env_mode="" + + [[ -n "$key" ]] || backup_die "env key is required." + [[ "$value" != *$'\n'* ]] || backup_die "env value for $key must not contain a newline." + + mkdir -p "$(dirname "$env_file")" + tmp_file="$(mktemp "$env_file.tmp.XXXXXX")" + + if [[ -f "$env_file" ]]; then + env_uid="$(stat -c '%u' "$env_file")" + env_gid="$(stat -c '%g' "$env_file")" + env_mode="$(stat -c '%a' "$env_file")" + awk -v key="$key" -v value="$value" ' + BEGIN { written = 0 } + $0 ~ "^[[:space:]]*" key "=" { + print key "=" value + written = 1 + next + } + { print } + END { + if (!written) { + print key "=" value + } + } + ' "$env_file" >"$tmp_file" + else + printf '%s=%s\n' "$key" "$value" >"$tmp_file" + fi + + if [[ -n "$env_mode" ]]; then + chmod "$env_mode" "$tmp_file" + else + chmod 600 "$tmp_file" + fi + + if [[ -n "$env_uid" && -n "$env_gid" ]]; then + chown "$env_uid:$env_gid" "$tmp_file" 2>/dev/null || true + fi + + mv "$tmp_file" "$env_file" +} + +print_authorize_url() { + [[ -n "$client_id" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_ID is required." + [[ -n "$redirect_uri" ]] || backup_die "WORKS_DRIVE_OAUTH_REDIRECT_URI is required." + + printf '%s?client_id=%s&redirect_uri=%s&scope=%s&response_type=code&state=%s\n' \ + "$authorize_url" \ + "$(urlencode "$client_id")" \ + "$(urlencode "$redirect_uri")" \ + "$(urlencode "$scope")" \ + "$(urlencode "${WORKS_DRIVE_OAUTH_STATE:-baron-sso-backup}")" +} + +request_refresh_token_grant() { + require_oauth_client + + local refresh_token="${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" + local response + local response_body + local http_status + + [[ -n "$refresh_token" ]] || backup_die "WORKS_DRIVE_OAUTH_REFRESH_TOKEN is required for refresh-token grant." + backup_require_command "$curl_bin" + + response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=refresh_token" \ + --data-urlencode "refresh_token=$refresh_token" \ + --data-urlencode "client_id=$client_id" \ + --data-urlencode "client_secret=$client_secret" \ + "$token_url")" + split_curl_response "$response" response_body http_status + + if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then + backup_die "WORKS refresh token request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" + fi + + printf '%s\n' "$response_body" +} + +request_authorization_code_grant() { + require_oauth_client + + local code="${WORKS_DRIVE_AUTH_CODE:-}" + local response + local response_body + local http_status + + if [[ -z "$code" && -n "${WORKS_DRIVE_AUTH_CALLBACK_URL:-}" ]]; then + code="$(extract_code_from_callback_url "$WORKS_DRIVE_AUTH_CALLBACK_URL")" + fi + + [[ -n "$code" ]] || backup_die "WORKS_DRIVE_AUTH_CODE or WORKS_DRIVE_AUTH_CALLBACK_URL is required for authorization-code grant." + [[ -n "$redirect_uri" ]] || backup_die "WORKS_DRIVE_OAUTH_REDIRECT_URI is required for authorization-code grant." + backup_require_command "$curl_bin" + + response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=authorization_code" \ + --data-urlencode "code=$code" \ + --data-urlencode "client_id=$client_id" \ + --data-urlencode "client_secret=$client_secret" \ + --data-urlencode "redirect_uri=$redirect_uri" \ + "$token_url")" + split_curl_response "$response" response_body http_status + + if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then + backup_die "WORKS authorization code token request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" + fi + + printf '%s\n' "$response_body" +} + +persist_token_response() { + local response_body="$1" + local refresh_token + local expires_in + + expires_in="$(jq -r '.expires_in // empty' <<<"$response_body")" + refresh_token="$(jq -r '.refresh_token // empty' <<<"$response_body")" + + if [[ -n "$refresh_token" ]]; then + write_env_value WORKS_DRIVE_OAUTH_REFRESH_TOKEN "$refresh_token" + backup_log "WORKS Drive refresh token updated: $env_file" + else + backup_log "WORKS token refresh succeeded without a rotated refresh token." + fi + + if [[ "$set_auth_mode" == "true" ]]; then + write_env_value WORKS_DRIVE_AUTH_MODE refresh-token + fi + + if [[ -n "$expires_in" ]]; then + backup_log "WORKS Drive access token issued. expires_in=$expires_in" + else + backup_log "WORKS Drive access token issued." + fi +} + +case "$token_grant" in + print-authorize-url) + print_authorize_url + ;; + refresh-token) + persist_token_response "$(request_refresh_token_grant)" + ;; + authorization-code) + persist_token_response "$(request_authorization_code_grant)" + ;; + *) + backup_die "unknown WORKS_DRIVE_TOKEN_GRANT: $token_grant. Expected refresh-token, authorization-code, or print-authorize-url." + ;; +esac diff --git a/scripts/backup/upload_cloud.sh b/scripts/backup/upload_cloud.sh index 5da455cb..eae372d8 100755 --- a/scripts/backup/upload_cloud.sh +++ b/scripts/backup/upload_cloud.sh @@ -17,6 +17,7 @@ if [[ -f "$repo_root/.env" ]]; then WORKS_DRIVE_ACCESS_TOKEN WORKS_DRIVE_ACCESS_TOKEN_FILE WORKS_DRIVE_ACCESS_TOKEN_CMD + WORKS_DRIVE_AUTH_MODE WORKS_DRIVE_OAUTH_SCOPE WORKS_DRIVE_SPLIT_SIZE WORKS_DRIVE_MAX_SINGLE_FILE_BYTES @@ -43,7 +44,7 @@ if [[ -f "$repo_root/.env" ]]; then declare -A env_override_values=() env_override_set=() for env_key in "${env_override_keys[@]}"; do - if [[ -v "$env_key" ]]; then + if [[ -n "${!env_key:-}" ]]; then env_override_set+=("$env_key") env_override_values["$env_key"]="${!env_key}" fi @@ -69,6 +70,7 @@ WORKS_DRIVE_PARENT_FILE_ID="${WORKS_DRIVE_PARENT_FILE_ID:-${WORKS_DRIVE_SHAREDRI dry_run="${WORKS_DRIVE_DRY_RUN:-false}" target="${WORKS_DRIVE_TARGET:-sharedrive}" +auth_mode="${WORKS_DRIVE_AUTH_MODE:-auto}" api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}" curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}" archive_dir="${WORKS_DRIVE_ARCHIVE_DIR:-/tmp/baron-sso-backup-upload}" @@ -81,6 +83,11 @@ upload_reports="${WORKS_DRIVE_UPLOAD_REPORTS:-true}" report_folder_name="${WORKS_DRIVE_REPORT_FOLDER_NAME:-reports}" report_dir="$backup_path/reports" +case "$auth_mode" in + auto | service-account | refresh-token) ;; + *) backup_die "unknown WORKS_DRIVE_AUTH_MODE: $auth_mode. Expected auto, service-account, or refresh-token." ;; +esac + if [[ -f "$backup_path" ]]; then report_dir="$(dirname "$backup_path")" fi @@ -330,6 +337,13 @@ request_refresh_access_token() { jq -er '.access_token' <<<"$response_body" } +service_account_credentials_configured() { + [[ -n "${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" ]] \ + && [[ -n "${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" ]] \ + && [[ -n "${WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT:-}" ]] \ + && { [[ -n "${WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY:-}" ]] || [[ -n "${WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE:-}" ]]; } +} + resolve_access_token() { if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN:-}" ]]; then printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN" @@ -347,12 +361,27 @@ resolve_access_token() { return fi - if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then - request_refresh_access_token - return - fi + case "$auth_mode" in + service-account) + request_service_account_token + ;; + refresh-token) + request_refresh_access_token + ;; + auto) + if service_account_credentials_configured; then + request_service_account_token + return + fi - request_service_account_token + if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then + request_refresh_access_token + return + fi + + request_service_account_token + ;; + esac } package_backup_path() { diff --git a/scripts/deploy/build_image_deploy_bundle.sh b/scripts/deploy/build_image_deploy_bundle.sh new file mode 100755 index 00000000..6a74c0b9 --- /dev/null +++ b/scripts/deploy/build_image_deploy_bundle.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" + +die() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +require_env() { + local key="$1" + [[ -n "${!key:-}" ]] || die "Missing required env: $key" +} + +host_from_url() { + local value="$1" + value="${value#https://}" + value="${value#http://}" + printf '%s' "${value%%/*}" +} + +require_env IMAGE_TAG +require_env IMAGE_DEPLOY_ENV +require_env IMAGE_DEPLOY_PORT_PREFIX +require_env IMAGE_DEPLOY_PUBLIC_URL +require_env ADMINFRONT_URL +require_env DEVFRONT_URL +require_env ORGFRONT_URL +require_env VITE_OIDC_AUTHORITY +require_env HARBOR_HOSTNAME + +if ! printf '%s' "$IMAGE_TAG" | grep -Eq '^v[0-9]+\.[0-9]{4}\.[0-9a-f]{4}$'; then + die "IMAGE_TAG must look like vX.YYMM.ab12 (got: $IMAGE_TAG)" +fi + +case "$IMAGE_DEPLOY_ENV" in + stage | staging) + app_env="stage" + default_instance_name="stage" + ;; + production | prod) + app_env="production" + default_instance_name="prod" + ;; + *) + die "IMAGE_DEPLOY_ENV must be stage or production" + ;; +esac + +instance_name="${IMAGE_DEPLOY_INSTANCE_NAME:-$default_instance_name}" +bundle_dir="${IMAGE_DEPLOY_BUNDLE_DIR:-$PWD/${instance_name}-image-deploy-bundle}" +bundle_file="${IMAGE_DEPLOY_BUNDLE_FILE:-$PWD/${instance_name}-image-deploy-bundle.tgz}" +compose_template="${IMAGE_DEPLOY_COMPOSE_TEMPLATE:-$repo_root/deploy/templates/docker-compose.images.yaml}" + +rm -rf "$bundle_dir" +TARGET_DIR="$bundle_dir" bash "$repo_root/deploy/create-instance.sh" "$instance_name" "$IMAGE_DEPLOY_PORT_PREFIX" +cp "$compose_template" "$bundle_dir/docker-compose.yml" + +public_host="$(host_from_url "$IMAGE_DEPLOY_PUBLIC_URL")" +admin_host="$(host_from_url "$ADMINFRONT_URL")" +dev_host="$(host_from_url "$DEVFRONT_URL")" +org_host="$(host_from_url "$ORGFRONT_URL")" + +cat >"$bundle_dir/.env" <&2 + exit 1 +} + +require_env() { + local key="$1" + [[ -n "${!key:-}" ]] || die "Missing required env: $key" +} + +require_env IMAGE_DEPLOY_BUNDLE_FILE +require_env DEPLOY_HOST +require_env DEPLOY_USER +require_env DEPLOY_PATH +require_env HARBOR_ENDPOINT +require_env HARBOR_ROBOT_ACCOUNT +require_env HARBOR_ROBOT_KEY + +[[ -f "$IMAGE_DEPLOY_BUNDLE_FILE" ]] || die "bundle file not found: $IMAGE_DEPLOY_BUNDLE_FILE" + +remote_bundle="/tmp/baron-sso-image-deploy-$(date -u '+%Y%m%d%H%M%S').tgz" + +ssh-keyscan -H "$DEPLOY_HOST" >>~/.ssh/known_hosts +scp "$IMAGE_DEPLOY_BUNDLE_FILE" "${DEPLOY_USER}@${DEPLOY_HOST}:${remote_bundle}" + +echo "$HARBOR_ROBOT_KEY" | ssh "${DEPLOY_USER}@${DEPLOY_HOST}" \ + "set -euo pipefail; \ + mkdir -p '${DEPLOY_PATH}'; \ + tar -xzf '${remote_bundle}' -C '${DEPLOY_PATH}'; \ + cd '${DEPLOY_PATH}'; \ + chmod 600 .env; \ + docker network inspect traefik-public >/dev/null 2>&1 || docker network create traefik-public; \ + docker login '${HARBOR_ENDPOINT}' -u '${HARBOR_ROBOT_ACCOUNT}' --password-stdin; \ + docker compose --env-file .env -f docker-compose.yml pull; \ + docker compose --env-file .env -f docker-compose.yml up -d --remove-orphans; \ + docker compose --env-file .env -f docker-compose.yml ps" diff --git a/scripts/docker-image/upload_works_drive.sh b/scripts/docker-image/upload_works_drive.sh new file mode 100755 index 00000000..9eb2310b --- /dev/null +++ b/scripts/docker-image/upload_works_drive.sh @@ -0,0 +1,632 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/../.." && pwd)" +source "$repo_root/scripts/backup/lib/common.sh" + +if [[ -f "$repo_root/.env" ]]; then + env_override_keys=( + DOCKER_IMAGE_REF + IMAGE_REF + WORKS_DOCKER_COMMIT_CONTAINER + DOCKER_COMMIT_CONTAINER + WORKS_DOCKER_IMAGE_ARCHIVE_DIR + WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR + WORKS_DRIVE_TARGET + WORKS_DRIVE_SHARED_DRIVE_ID + WORKS_DRIVE_PARENT_FILE_ID + WORKS_DRIVE_USER_ID + WORKS_DRIVE_GROUP_ID + WORKS_DRIVE_SHARED_FOLDER_ID + WORKS_DRIVE_ACCESS_TOKEN + WORKS_DRIVE_ACCESS_TOKEN_FILE + WORKS_DRIVE_ACCESS_TOKEN_CMD + WORKS_DRIVE_OAUTH_SCOPE + WORKS_DRIVE_OVERWRITE + WORKS_DRIVE_DRY_RUN + WORKS_DRIVE_CURL_BIN + WORKS_DRIVE_SHAREDRIVE_ID + WORKS_DRIVE_OAUTH_CLIENT_ID + WORKS_DRIVE_OAUTH_CLIENT_SECRET + WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT + WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY + WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE + WORKS_DRIVE_OAUTH_REFRESH_TOKEN + WORKS_SHAREDRIVE_ID + WORKS_ADMIN_API_BASE_URL + WORKS_ADMIN_OAUTH_TOKEN_URL + ) + declare -A env_override_values=() + env_override_set=() + for env_key in "${env_override_keys[@]}"; do + if [[ -v "$env_key" ]]; then + env_override_set+=("$env_key") + env_override_values["$env_key"]="${!env_key}" + fi + done + + set -a + # shellcheck source=/dev/null + source "$repo_root/.env" + set +a + + for env_key in "${env_override_set[@]}"; do + printf -v "$env_key" '%s' "${env_override_values[$env_key]}" + export "$env_key" + done +fi + +backup_require_command docker +backup_require_command jq +backup_require_command sha256sum +backup_require_command stat +backup_require_command zstd + +image_ref="${DOCKER_IMAGE_REF:-${IMAGE_REF:-}}" +[[ -n "$image_ref" ]] || backup_die "DOCKER_IMAGE_REF is required. Example: DOCKER_IMAGE_REF=registry.example/baron_sso/backend:v1.2606.ab12" + +commit_container="${WORKS_DOCKER_COMMIT_CONTAINER:-${DOCKER_COMMIT_CONTAINER:-}}" +archive_root="${WORKS_DOCKER_IMAGE_ARCHIVE_DIR:-/tmp/baron-sso-docker-image-upload}" +image_root_dir="${WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-docker-build-image}" +dry_run="${WORKS_DRIVE_DRY_RUN:-false}" +target="${WORKS_DRIVE_TARGET:-sharedrive}" +api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}" +curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}" +overwrite="${WORKS_DRIVE_OVERWRITE:-true}" +upload_scope="${WORKS_DRIVE_OAUTH_SCOPE:-file}" + +WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_SHARED_DRIVE_ID:-${WORKS_DRIVE_SHAREDRIVE_ID:-${WORKS_SHAREDRIVE_ID:-}}}" + +urlencode_path() { + jq -nr --arg value "$1" '$value|@uri' +} + +split_curl_response() { + local response="$1" + local __body_var="$2" + local __status_var="$3" + local status + local body + + status="$(tail -n 1 <<<"$response")" + if [[ "$status" =~ ^[0-9][0-9][0-9]$ ]]; then + body="$(sed '$d' <<<"$response")" + else + status="200" + body="$response" + fi + + printf -v "$__body_var" '%s' "$body" + printf -v "$__status_var" '%s' "$status" +} + +redact_for_log() { + sed -E 's/("(access_token|refresh_token|assertion|client_secret|Authorization)"[[:space:]]*:[[:space:]]*)"[^"]*"/\1"REDACTED"/Ig' +} + +resolve_target_upload_endpoint() { + local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}" + local encoded_parent="" + + if [[ -n "$parent_file_id" ]]; then + encoded_parent="$(urlencode_path "$parent_file_id")" + fi + + case "$target" in + sharedrive) + [[ -n "${WORKS_DRIVE_SHARED_DRIVE_ID:-}" ]] || backup_die "WORKS_DRIVE_SHARED_DRIVE_ID is required when WORKS_DRIVE_TARGET=sharedrive." + local shared_drive_id + shared_drive_id="$(urlencode_path "$WORKS_DRIVE_SHARED_DRIVE_ID")" + if [[ -n "$encoded_parent" ]]; then + printf '%s/v1.0/sharedrives/%s/files/%s\n' "$api_base_url" "$shared_drive_id" "$encoded_parent" + else + printf '%s/v1.0/sharedrives/%s/files\n' "$api_base_url" "$shared_drive_id" + fi + ;; + mydrive) + local user_id="${WORKS_DRIVE_USER_ID:-me}" + user_id="$(urlencode_path "$user_id")" + if [[ -n "$encoded_parent" ]]; then + printf '%s/v1.0/users/%s/drive/files/%s\n' "$api_base_url" "$user_id" "$encoded_parent" + else + printf '%s/v1.0/users/%s/drive/files\n' "$api_base_url" "$user_id" + fi + ;; + group) + [[ -n "${WORKS_DRIVE_GROUP_ID:-}" ]] || backup_die "WORKS_DRIVE_GROUP_ID is required when WORKS_DRIVE_TARGET=group." + local group_id + group_id="$(urlencode_path "$WORKS_DRIVE_GROUP_ID")" + if [[ -n "$encoded_parent" ]]; then + printf '%s/v1.0/groups/%s/folder/files/%s\n' "$api_base_url" "$group_id" "$encoded_parent" + else + printf '%s/v1.0/groups/%s/folder/files\n' "$api_base_url" "$group_id" + fi + ;; + sharedfolder) + [[ -n "${WORKS_DRIVE_SHARED_FOLDER_ID:-}" ]] || backup_die "WORKS_DRIVE_SHARED_FOLDER_ID is required when WORKS_DRIVE_TARGET=sharedfolder." + local user_id="${WORKS_DRIVE_USER_ID:-me}" + local shared_folder_id + user_id="$(urlencode_path "$user_id")" + shared_folder_id="$(urlencode_path "$WORKS_DRIVE_SHARED_FOLDER_ID")" + if [[ -n "$encoded_parent" ]]; then + printf '%s/v1.0/users/%s/drive/sharedfolders/%s/files/%s\n' "$api_base_url" "$user_id" "$shared_folder_id" "$encoded_parent" + else + printf '%s/v1.0/users/%s/drive/sharedfolders/%s/files\n' "$api_base_url" "$user_id" "$shared_folder_id" + fi + ;; + *) + backup_die "unknown WORKS_DRIVE_TARGET: $target" + ;; + esac +} + +resolve_target_children_endpoint() { + local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}" + printf '%s/children\n' "$(resolve_target_upload_endpoint "$parent_file_id")" +} + +resolve_target_create_folder_endpoint() { + local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}" + printf '%s/createfolder\n' "$(resolve_target_upload_endpoint "$parent_file_id")" +} + +base64url() { + openssl base64 -A | tr '+/' '-_' | tr -d '=' +} + +build_jwt_assertion() { + backup_require_command openssl + + local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" + local service_account="${WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT:-}" + local private_key="${WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY:-}" + local private_key_file="${WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE:-}" + local key_file="" + local temp_key_file="" + local now + local exp + local header + local payload + local signing_input + + [[ -n "$client_id" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_ID is required for service-account token mode." + [[ -n "$service_account" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT is required for service-account token mode." + + if [[ -n "$private_key" ]]; then + temp_key_file="$(mktemp /tmp/baron-sso-works-key.XXXXXX)" + printf '%s\n' "$private_key" >"$temp_key_file" + key_file="$temp_key_file" + elif [[ -n "$private_key_file" ]]; then + if [[ "$private_key_file" != /* ]]; then + private_key_file="$repo_root/$private_key_file" + fi + backup_require_path "$private_key_file" + key_file="$private_key_file" + else + backup_die "WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY or WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE is required for service-account token mode." + fi + + now="$(date +%s)" + exp="$((now + 3600))" + header="$(printf '{"alg":"RS256","typ":"JWT"}' | base64url)" + payload="$(jq -cn \ + --arg iss "$client_id" \ + --arg sub "$service_account" \ + --argjson iat "$now" \ + --argjson exp "$exp" \ + '{iss:$iss, sub:$sub, iat:$iat, exp:$exp}' | base64url)" + signing_input="${header}.${payload}" + + printf '%s' "$signing_input" \ + | openssl dgst -sha256 -sign "$key_file" -binary \ + | base64url \ + | while IFS= read -r signature; do + printf '%s.%s\n' "$signing_input" "$signature" + done + + if [[ -n "$temp_key_file" ]]; then + rm -f "$temp_key_file" + fi +} + +request_service_account_token() { + local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" + local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" + local token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}" + local assertion + local response + local response_body + local http_status + + [[ -n "$client_secret" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_SECRET is required for service-account token mode." + assertion="$(build_jwt_assertion)" + + response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" \ + --data-urlencode "assertion=$assertion" \ + --data-urlencode "client_id=$client_id" \ + --data-urlencode "client_secret=$client_secret" \ + --data-urlencode "scope=$upload_scope" \ + "$token_url")" + split_curl_response "$response" response_body http_status + + if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then + backup_die "WORKS token request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" + fi + + jq -er '.access_token' <<<"$response_body" +} + +request_refresh_access_token() { + local client_id="${WORKS_DRIVE_OAUTH_CLIENT_ID:-}" + local client_secret="${WORKS_DRIVE_OAUTH_CLIENT_SECRET:-}" + local refresh_token="${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" + local token_url="${WORKS_ADMIN_OAUTH_TOKEN_URL:-https://auth.worksmobile.com/oauth2/v2.0/token}" + local response + local response_body + local http_status + + [[ -n "$client_id" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_ID is required for refresh-token mode." + [[ -n "$client_secret" ]] || backup_die "WORKS_DRIVE_OAUTH_CLIENT_SECRET is required for refresh-token mode." + [[ -n "$refresh_token" ]] || backup_die "WORKS_DRIVE_OAUTH_REFRESH_TOKEN is required for refresh-token mode." + + response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ + -H "Content-Type: application/x-www-form-urlencoded" \ + --data-urlencode "grant_type=refresh_token" \ + --data-urlencode "refresh_token=$refresh_token" \ + --data-urlencode "client_id=$client_id" \ + --data-urlencode "client_secret=$client_secret" \ + "$token_url")" + split_curl_response "$response" response_body http_status + + if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then + backup_die "WORKS refresh token request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" + fi + + jq -er '.access_token' <<<"$response_body" +} + +resolve_access_token() { + if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN:-}" ]]; then + printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN" + return + fi + + if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN_FILE:-}" ]]; then + backup_require_path "$WORKS_DRIVE_ACCESS_TOKEN_FILE" + sed -n '1p' "$WORKS_DRIVE_ACCESS_TOKEN_FILE" + return + fi + + if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN_CMD:-}" ]]; then + sh -c "$WORKS_DRIVE_ACCESS_TOKEN_CMD" + return + fi + + if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then + request_refresh_access_token + return + fi + + request_service_account_token +} + +list_child_folders() { + local access_token="$1" + local endpoint="$2" + local response + local response_body + local http_status + + response="$("$curl_bin" -sS -w $'\n%{http_code}' -X GET \ + -H "Authorization: Bearer $access_token" \ + "$endpoint")" + split_curl_response "$response" response_body http_status + + if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then + backup_die "WORKS folder list request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" + fi + + printf '%s\n' "$response_body" +} + +create_child_folder() { + local access_token="$1" + local endpoint="$2" + local folder_name="$3" + local payload + local response + local response_body + local http_status + + payload="$(jq -cn --arg fileName "$folder_name" '{fileName:$fileName}')" + response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ + -H "Authorization: Bearer $access_token" \ + -H "Content-Type: application/json; charset=UTF-8" \ + -d "$payload" \ + "$endpoint")" + split_curl_response "$response" response_body http_status + + if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then + backup_die "WORKS folder create request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" + fi + + jq -er '.fileId // .id' <<<"$response_body" +} + +ensure_child_folder() { + local access_token="$1" + local parent_file_id="$2" + local folder_name="$3" + local children_endpoint + local create_folder_endpoint + local children_json + local folder_id + + children_endpoint="$(resolve_target_children_endpoint "$parent_file_id")" + create_folder_endpoint="$(resolve_target_create_folder_endpoint "$parent_file_id")" + children_json="$(list_child_folders "$access_token" "$children_endpoint")" + folder_id="$(jq -er --arg name "$folder_name" ' + [ + (.files // .children // .items // [])[] + | select((.fileName // .name) == $name) + | select(((.fileType // .type // "") | ascii_downcase) == "folder") + | .fileId // .id + ][0] // empty + ' <<<"$children_json" 2>/dev/null || true)" + + if [[ -n "$folder_id" ]]; then + printf '%s\n' "$folder_id" + return + fi + + create_child_folder "$access_token" "$create_folder_endpoint" "$folder_name" +} + +ensure_folder_path() { + local access_token="$1" + local path="$2" + local parent_file_id="${WORKS_DRIVE_PARENT_FILE_ID:-}" + local component + + IFS='/' read -r -a components <<<"$path" + for component in "${components[@]}"; do + [[ -n "$component" ]] || continue + parent_file_id="$(ensure_child_folder "$access_token" "$parent_file_id" "$component")" + done + + printf '%s\n' "$parent_file_id" +} + +create_upload_url() { + local access_token="$1" + local endpoint="$2" + local file_path="$3" + local file_name + local file_size + local payload + local response + local response_body + local http_status + + file_name="$(basename "$file_path")" + file_size="$(stat -c '%s' "$file_path")" + payload="$(jq -cn \ + --arg fileName "$file_name" \ + --argjson fileSize "$file_size" \ + --argjson overwrite "$overwrite" \ + '{fileName:$fileName, fileSize:$fileSize, overwrite:$overwrite}')" + + response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ + -H "Authorization: Bearer $access_token" \ + -H "Content-Type: application/json; charset=UTF-8" \ + -d "$payload" \ + "$endpoint")" + split_curl_response "$response" response_body http_status + + if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then + backup_die "WORKS upload URL request failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" + fi + + jq -er '.uploadUrl' <<<"$response_body" +} + +upload_file_to_url() { + local access_token="$1" + local upload_url="$2" + local file_path="$3" + local file_name + local response + local response_body + local http_status + + file_name="$(basename "$file_path")" + response="$("$curl_bin" -sS -w $'\n%{http_code}' -X POST \ + -H "Authorization: Bearer $access_token" \ + -F "Filedata=@${file_path};filename=${file_name};type=application/octet-stream" \ + "$upload_url")" + split_curl_response "$response" response_body http_status + + if [[ "$http_status" -lt 200 || "$http_status" -ge 300 ]]; then + backup_die "WORKS file upload failed (HTTP $http_status): $(printf '%s' "$response_body" | redact_for_log)" + fi + + printf '%s\n' "$response_body" +} + +derive_repository_and_tag() { + local ref="$1" + local ref_without_digest="${ref%@*}" + local last_slash_index=-1 + local last_colon_index=-1 + local i + local char + local repository_with_registry + local first_component + local rest + + if [[ "$ref" == *@* ]]; then + backup_die "digest image refs are not supported for WORKS image archive upload. Use a tagged image ref." + fi + + for ((i = 0; i < ${#ref_without_digest}; i += 1)); do + char="${ref_without_digest:i:1}" + [[ "$char" == "/" ]] && last_slash_index="$i" + [[ "$char" == ":" ]] && last_colon_index="$i" + done + + if [[ "$last_colon_index" -le "$last_slash_index" ]]; then + backup_die "DOCKER_IMAGE_REF must include an explicit tag: $ref" + fi + + repository_with_registry="${ref_without_digest:0:last_colon_index}" + image_tag="${ref_without_digest:last_colon_index + 1}" + + first_component="${repository_with_registry%%/*}" + if [[ "$repository_with_registry" == */* ]]; then + rest="${repository_with_registry#*/}" + else + rest="$repository_with_registry" + fi + + if [[ "$first_component" == *.* || "$first_component" == *:* || "$first_component" == "localhost" ]]; then + image_repository="$rest" + else + image_repository="$repository_with_registry" + fi + + [[ -n "$image_repository" ]] || backup_die "image repository could not be derived from DOCKER_IMAGE_REF: $ref" + [[ "$image_repository" =~ ^[A-Za-z0-9._/-]+$ ]] || backup_die "image repository contains unsupported characters: $image_repository" + [[ "$image_tag" =~ ^[A-Za-z0-9._-]+$ ]] || backup_die "image tag contains unsupported characters: $image_tag" +} + +derive_repository_and_tag "$image_ref" + +remote_path="${image_root_dir}/${image_repository}/${image_tag}" +artifact_dir="${archive_root}/${image_repository}/${image_tag}" +mkdir -p "$artifact_dir" + +tar_file="$artifact_dir/image.tar" +archive_file="$artifact_dir/image.tar.zst" +checksum_file="$artifact_dir/image.tar.zst.sha256" +manifest_file="$artifact_dir/manifest.json" +upload_report_file="$artifact_dir/works-upload.json" + +rm -f "$tar_file" "$archive_file" "$checksum_file" "$manifest_file" "$upload_report_file" + +if [[ -n "$commit_container" ]]; then + backup_log "Committing container $commit_container to $image_ref" + docker commit "$commit_container" "$image_ref" >/dev/null +fi + +backup_log "Saving Docker image $image_ref" +docker save -o "$tar_file" "$image_ref" + +backup_log "Compressing image archive with zstd" +zstd -f -19 -o "$archive_file" "$tar_file" >/dev/null +rm -f "$tar_file" + +archive_sha256="$(sha256sum "$archive_file" | awk '{print $1}')" +printf '%s %s\n' "$archive_sha256" "$(basename "$archive_file")" >"$checksum_file" + +image_id="$(docker image inspect "$image_ref" --format '{{.Id}}' 2>/dev/null || printf 'unknown')" +archive_size="$(stat -c '%s' "$archive_file")" +git_commit="$(backup_git_commit "$repo_root")" + +jq -n \ + --arg createdAt "$(backup_utc_now)" \ + --arg imageRef "$image_ref" \ + --arg repository "$image_repository" \ + --arg tag "$image_tag" \ + --arg sourceContainer "$commit_container" \ + --arg imageId "$image_id" \ + --arg gitCommit "$git_commit" \ + --arg remotePath "$remote_path" \ + --arg archiveFile "$(basename "$archive_file")" \ + --arg archiveSha256 "$archive_sha256" \ + --argjson archiveSize "$archive_size" \ + '{ + schema_version: 1, + format: "docker-save-zstd", + created_at: $createdAt, + image_ref: $imageRef, + repository: $repository, + tag: $tag, + source_container: $sourceContainer, + docker_image_id: $imageId, + git_commit: $gitCommit, + remote_path: $remotePath, + restore_command: ("zstd -d -c " + $archiveFile + " | docker load"), + archive: { + file_name: $archiveFile, + size_bytes: $archiveSize, + sha256: $archiveSha256 + } + }' >"$manifest_file" + +upload_files=("$archive_file" "$checksum_file" "$manifest_file") + +if [[ "$dry_run" == "true" ]]; then + jq -n \ + --arg createdAt "$(backup_utc_now)" \ + --arg status "planned" \ + --arg remotePath "$remote_path" \ + --arg artifactDir "$artifact_dir" \ + --argjson files "$(printf '%s\n' "${upload_files[@]}" | jq -R '{file_path:., file_name:(. | split("/")[-1]), status:"planned"}' | jq -s '.')" \ + '{ + created_at: $createdAt, + status: $status, + remote_path: $remotePath, + artifact_dir: $artifactDir, + files: $files + }' >"$upload_report_file" + backup_log "Dry run: packaged image artifacts at $artifact_dir" + backup_log "Dry run: would upload to WORKS Drive path $remote_path" + exit 0 +fi + +backup_require_command "$curl_bin" +access_token="$(resolve_access_token)" +backup_log "Resolving WORKS Drive folder path: $remote_path" +target_folder_id="$(ensure_folder_path "$access_token" "$remote_path")" +upload_endpoint="$(resolve_target_upload_endpoint "$target_folder_id")" + +uploaded_items="[]" +for file_path in "${upload_files[@]}"; do + backup_require_path "$file_path" + backup_log "Creating WORKS Drive upload URL for $(basename "$file_path")" + upload_url="$(create_upload_url "$access_token" "$upload_endpoint" "$file_path")" + backup_log "Uploading $(basename "$file_path") to WORKS Drive" + upload_response="$(upload_file_to_url "$access_token" "$upload_url" "$file_path")" + upload_response_json="$(jq -c '.' <<<"${upload_response:-{}}" 2>/dev/null || printf '{}')" + uploaded_items="$(jq \ + --arg fileName "$(basename "$file_path")" \ + --arg filePath "$file_path" \ + --argjson fileSize "$(stat -c '%s' "$file_path")" \ + --arg status "uploaded" \ + --arg response "$upload_response_json" \ + '. + [{file_name:$fileName, file_path:$filePath, file_size:$fileSize, status:$status, response:($response | fromjson? // {})}]' \ + <<<"$uploaded_items")" +done + +jq -n \ + --arg createdAt "$(backup_utc_now)" \ + --arg status "uploaded" \ + --arg remotePath "$remote_path" \ + --arg artifactDir "$artifact_dir" \ + --arg target "$target" \ + --arg folderId "$target_folder_id" \ + --argjson files "$uploaded_items" \ + '{ + created_at: $createdAt, + status: $status, + target: $target, + remote_path: $remotePath, + target_folder_id: $folderId, + artifact_dir: $artifactDir, + files: $files + }' >"$upload_report_file" + +backup_log "Upload complete: $upload_report_file" diff --git a/test/backup_make_targets_test.sh b/test/backup_make_targets_test.sh index 673421be..67b9de3f 100755 --- a/test/backup_make_targets_test.sh +++ b/test/backup_make_targets_test.sh @@ -79,14 +79,27 @@ for target in dump-verify restore-verify dump-list restore-plan; do assert_dry_run_contains "$target_dry_run" "scripts/backup/" done +upload_default_dry_run="$( + make --dry-run --always-make -C "$repo_root" upload-cloud BACKUP="backups/example" 2>&1 +)" + +assert_dry_run_contains "$upload_default_dry_run" "scripts/backup/upload_cloud.sh" +if grep -Fq 'WORKS_DRIVE_AUTH_MODE=""' <<<"$upload_default_dry_run"; then + fail "make upload-cloud must not pass an empty WORKS_DRIVE_AUTH_MODE that masks .env." +fi +if grep -Fq 'WORKS_DRIVE_DRY_RUN=""' <<<"$upload_default_dry_run"; then + fail "make upload-cloud must not pass an empty WORKS_DRIVE_DRY_RUN that masks .env." +fi + upload_dry_run="$( - make --dry-run --always-make -C "$repo_root" upload-cloud BACKUP="backups/example" WORKS_DRIVE_DRY_RUN=true 2>&1 + make --dry-run --always-make -C "$repo_root" upload-cloud BACKUP="backups/example" WORKS_DRIVE_DRY_RUN=true WORKS_DRIVE_AUTH_MODE=refresh-token 2>&1 )" assert_dry_run_contains "$upload_dry_run" "docker run" assert_dry_run_contains "$upload_dry_run" "scripts/backup/upload_cloud.sh" assert_dry_run_contains "$upload_dry_run" "BACKUP=\"backups/example\"" assert_dry_run_contains "$upload_dry_run" "WORKS_DRIVE_DRY_RUN=\"true\"" +assert_dry_run_contains "$upload_dry_run" "WORKS_DRIVE_AUTH_MODE=\"refresh-token\"" if make -C "$repo_root" BACKUP_USE_DOCKER=false restore >/tmp/baron-sso-restore-missing.out 2>&1; then fail "make restore must fail when BACKUP and CONFIRM_RESTORE are not provided." diff --git a/test/backup_upload_cloud_policy_test.sh b/test/backup_upload_cloud_policy_test.sh index a06e8adc..80b95719 100755 --- a/test/backup_upload_cloud_policy_test.sh +++ b/test/backup_upload_cloud_policy_test.sh @@ -37,6 +37,17 @@ printf '%s\n' "$*" >>"${FAKE_CURL_LOG}" last_arg="${!#}" case "$last_arg" in + https://auth.example.test/token) + if [[ "$*" == *"grant_type=refresh_token"* ]]; then + if [[ "${ALLOW_REFRESH_TOKEN_GRANT:-false}" == "true" ]]; then + printf '{"access_token":"refresh-token-access-token"}' + exit 0 + fi + echo "refresh-token grant must not be used when service-account credentials are configured" >&2 + exit 2 + fi + printf '{"access_token":"service-account-token"}' + ;; https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/folder-1/children) printf '{"files":[]}' ;; @@ -67,12 +78,31 @@ cat >"$fake_bin/zstd" <<'EOF' cat EOF chmod +x "$fake_bin/zstd" +cat >"$fake_bin/openssl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +case "${1:-}" in + base64) + base64 | tr -d '\n' + ;; + dgst) + cat >/dev/null + printf 'signed-fixture' + ;; + *) + echo "unexpected openssl command: $*" >&2 + exit 2 + ;; +esac +EOF +chmod +x "$fake_bin/openssl" WORKS_DRIVE_ACCESS_TOKEN="test-access-token" \ WORKS_DRIVE_TARGET="sharedrive" \ WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \ WORKS_DRIVE_PARENT_FILE_ID="folder-1" \ WORKS_DRIVE_CURL_BIN="$fake_curl" \ +WORKS_DRIVE_ARCHIVE_DIR="$tmp_dir/archive" \ FAKE_CURL_LOG="$curl_log" \ PATH="$fake_bin:$PATH" \ BACKUP="$backup_dir" \ @@ -95,14 +125,108 @@ report_file="$backup_dir/reports/cloud-upload.json" [[ -f "$report_file" ]] || fail "upload must write reports/cloud-upload.json." jq -e '.target == "sharedrive" and .files[0].status == "uploaded" and .report_files[0].status == "uploaded" and (.report_files[0].file_name | test("^backup-report-[0-9]{8}-[0-9]{6}Z[.]md$"))' "$report_file" >/dev/null || fail "upload report must include timestamped markdown report file status." +service_account_curl_log="$tmp_dir/service-account-curl.log" +WORKS_DRIVE_AUTH_MODE="auto" \ +WORKS_DRIVE_ACCESS_TOKEN="" \ +WORKS_DRIVE_ACCESS_TOKEN_FILE="" \ +WORKS_DRIVE_ACCESS_TOKEN_CMD="" \ +WORKS_DRIVE_OAUTH_REFRESH_TOKEN="stale-refresh-token" \ +WORKS_DRIVE_OAUTH_CLIENT_ID="client-id-1" \ +WORKS_DRIVE_OAUTH_CLIENT_SECRET="client-secret-1" \ +WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT="service-account-1" \ +WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY="private-key-fixture" \ +WORKS_ADMIN_OAUTH_TOKEN_URL="https://auth.example.test/token" \ +WORKS_DRIVE_TARGET="sharedrive" \ +WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \ +WORKS_DRIVE_PARENT_FILE_ID="folder-1" \ +WORKS_DRIVE_CURL_BIN="$fake_curl" \ +WORKS_DRIVE_ARCHIVE_DIR="$tmp_dir/service-account-archive" \ +FAKE_CURL_LOG="$service_account_curl_log" \ +PATH="$fake_bin:$PATH" \ +BACKUP="$backup_dir" \ +"$repo_root/scripts/backup/upload_cloud.sh" >"$tmp_dir/service-account-upload.out" + +grep -Fq "Upload complete" "$tmp_dir/service-account-upload.out" || fail "service-account upload must complete with fake curl." +grep -Fq "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" "$service_account_curl_log" || fail "service-account credentials must use jwt-bearer grant." +grep -Fq "Authorization: Bearer service-account-token" "$service_account_curl_log" || fail "service-account token must be used for WORKS API calls." +if grep -Fq "grant_type=refresh_token" "$service_account_curl_log"; then + fail "refresh-token grant must not be used when service-account credentials are configured." +fi + +empty_override_env_dir="$tmp_dir/empty-override-repo" +mkdir -p "$empty_override_env_dir" +cat >"$empty_override_env_dir/.env" <<'EOF' +WORKS_DRIVE_AUTH_MODE=refresh-token +WORKS_DRIVE_OAUTH_REFRESH_TOKEN=fresh-refresh-token +WORKS_DRIVE_OAUTH_CLIENT_ID=client-id-1 +WORKS_DRIVE_OAUTH_CLIENT_SECRET=client-secret-1 +WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT=service-account-1 +WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY=private-key-fixture +WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.example.test/token +WORKS_DRIVE_TARGET=sharedrive +WORKS_DRIVE_SHARED_DRIVE_ID=shared-drive-1 +WORKS_DRIVE_PARENT_FILE_ID=folder-1 +WORKS_DRIVE_ARCHIVE_DIR=/tmp/unused-by-test +EOF + +empty_override_curl_log="$tmp_dir/empty-override-curl.log" +BACKUP_REPO_ROOT="$empty_override_env_dir" \ +WORKS_DRIVE_AUTH_MODE="" \ +WORKS_DRIVE_ACCESS_TOKEN="" \ +WORKS_DRIVE_ACCESS_TOKEN_FILE="" \ +WORKS_DRIVE_ACCESS_TOKEN_CMD="" \ +WORKS_DRIVE_CURL_BIN="$fake_curl" \ +WORKS_DRIVE_ARCHIVE_DIR="$tmp_dir/empty-override-archive" \ +ALLOW_REFRESH_TOKEN_GRANT="true" \ +FAKE_CURL_LOG="$empty_override_curl_log" \ +PATH="$fake_bin:$PATH" \ +BACKUP="$backup_dir" \ +"$repo_root/scripts/backup/upload_cloud.sh" >"$tmp_dir/empty-override-upload.out" + +grep -Fq "Upload complete" "$tmp_dir/empty-override-upload.out" || fail "empty WORKS_DRIVE_AUTH_MODE override must still complete with .env value." +grep -Fq "grant_type=refresh_token" "$empty_override_curl_log" || fail "empty WORKS_DRIVE_AUTH_MODE override must not mask .env refresh-token mode." +if grep -Fq "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" "$empty_override_curl_log"; then + fail "empty WORKS_DRIVE_AUTH_MODE override must not fall back to jwt-bearer when .env requests refresh-token." +fi + +forced_refresh_curl_log="$tmp_dir/forced-refresh-curl.log" +WORKS_DRIVE_AUTH_MODE="refresh-token" \ +WORKS_DRIVE_ACCESS_TOKEN="" \ +WORKS_DRIVE_ACCESS_TOKEN_FILE="" \ +WORKS_DRIVE_ACCESS_TOKEN_CMD="" \ +WORKS_DRIVE_OAUTH_REFRESH_TOKEN="fresh-refresh-token" \ +WORKS_DRIVE_OAUTH_CLIENT_ID="client-id-1" \ +WORKS_DRIVE_OAUTH_CLIENT_SECRET="client-secret-1" \ +WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT="service-account-1" \ +WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY="private-key-fixture" \ +WORKS_ADMIN_OAUTH_TOKEN_URL="https://auth.example.test/token" \ +WORKS_DRIVE_TARGET="sharedrive" \ +WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \ +WORKS_DRIVE_PARENT_FILE_ID="folder-1" \ +WORKS_DRIVE_CURL_BIN="$fake_curl" \ +WORKS_DRIVE_ARCHIVE_DIR="$tmp_dir/forced-refresh-archive" \ +ALLOW_REFRESH_TOKEN_GRANT="true" \ +FAKE_CURL_LOG="$forced_refresh_curl_log" \ +PATH="$fake_bin:$PATH" \ +BACKUP="$backup_dir" \ +"$repo_root/scripts/backup/upload_cloud.sh" >"$tmp_dir/forced-refresh-upload.out" + +grep -Fq "Upload complete" "$tmp_dir/forced-refresh-upload.out" || fail "forced refresh-token upload must complete with fake curl." +grep -Fq "grant_type=refresh_token" "$forced_refresh_curl_log" || fail "WORKS_DRIVE_AUTH_MODE=refresh-token must use refresh-token grant." +grep -Fq "Authorization: Bearer refresh-token-access-token" "$forced_refresh_curl_log" || fail "forced refresh-token access token must be used for WORKS API calls." +if grep -Fq "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer" "$forced_refresh_curl_log"; then + fail "WORKS_DRIVE_AUTH_MODE=refresh-token must not use jwt-bearer grant." +fi + WORKS_DRIVE_DRY_RUN=true \ WORKS_DRIVE_TARGET="sharedrive" \ WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \ WORKS_DRIVE_PARENT_FILE_ID="folder-1" \ +WORKS_DRIVE_ARCHIVE_DIR="$tmp_dir/archive" \ PATH="$fake_bin:$PATH" \ BACKUP="$backup_dir" \ "$repo_root/scripts/backup/upload_cloud.sh" >"$tmp_dir/dry-run.out" grep -Fq "Dry run" "$tmp_dir/dry-run.out" || fail "dry-run must not require a token or call curl." -echo "OK: upload_cloud uploads current backup artifacts to WORKS Drive" +echo "OK: upload_cloud mock upload flow packages backup artifacts for WORKS Drive" diff --git a/test/devfront_port_policy_test.sh b/test/devfront_port_policy_test.sh new file mode 100644 index 00000000..dece348f --- /dev/null +++ b/test/devfront_port_policy_test.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +assert_contains() { + local file="$1" + local pattern="$2" + grep -Fq -- "$pattern" "$file" || fail "$file must contain: $pattern" +} + +assert_not_contains() { + local file="$1" + local pattern="$2" + if grep -Fq -- "$pattern" "$file"; then + fail "$file must not contain stale devfront port pattern: $pattern" + fi +} + +assert_root_service_contains() { + local service="$1" + local pattern="$2" + awk -v service=" $service:" ' + $0 == service { in_service = 1; next } + in_service && /^ [[:alnum:]_-]+:/ { in_service = 0 } + in_service { print } + ' "$ROOT_DIR/docker-compose.yaml" | grep -Fq -- "$pattern" || fail "docker-compose.yaml $service service must contain: $pattern" +} + +DEVFRONT_DOCKERFILE="$ROOT_DIR/devfront/Dockerfile" +DEVFRONT_VITE_CONFIG="$ROOT_DIR/devfront/vite.config.ts" +DEVFRONT_RUNTIME="$ROOT_DIR/devfront/scripts/runtime-mode.sh" +DEVFRONT_DEPLOY_VITE_CONFIG="$ROOT_DIR/deploy/templates/devfront/vite.config.ts" + +assert_contains "$DEVFRONT_DOCKERFILE" "EXPOSE 5174" +assert_contains "$DEVFRONT_DOCKERFILE" 'CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5174"]' +assert_contains "$DEVFRONT_DOCKERFILE" "ENV PORT=5174" +assert_not_contains "$DEVFRONT_DOCKERFILE" "EXPOSE 5173" +assert_not_contains "$DEVFRONT_DOCKERFILE" '"--port", "5173"' +assert_not_contains "$DEVFRONT_DOCKERFILE" "ENV PORT=5173" + +assert_contains "$DEVFRONT_VITE_CONFIG" "port: 5174" +assert_contains "$DEVFRONT_RUNTIME" "npm run preview -- --host 0.0.0.0 --port 5174" +assert_contains "$DEVFRONT_RUNTIME" "npm run dev -- --host 0.0.0.0 --port 5174" +assert_contains "$DEVFRONT_DEPLOY_VITE_CONFIG" "port: 5174" + +assert_root_service_contains "devfront" 'command: ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5174"]' +assert_root_service_contains "devfront" '- "${DEVFRONT_PORT:-5174}:5174"' +assert_root_service_contains "orgfront" '- "${ORGFRONT_PORT:-5175}:5175"' + +for file in \ + "$ROOT_DIR/docker/docker-compose.template.yaml" \ + "$ROOT_DIR/docker/docker-compose.staging.template.yaml" \ + "$ROOT_DIR/docker/staging_pull_compose.template.yaml" \ + "$ROOT_DIR/deploy/templates/docker-compose.yaml" +do + assert_contains "$file" "devfront:" + assert_not_contains "$file" '${DEVFRONT_PORT:-5174}:5173' + assert_not_contains "$file" '${DEVFRONT_PORT}:5173' +done + +assert_contains "$ROOT_DIR/docker/docker-compose.template.yaml" '${DEVFRONT_PORT:-5174}:5174' +assert_contains "$ROOT_DIR/docker/docker-compose.staging.template.yaml" '${DEVFRONT_PORT:-5174}:5174' +assert_contains "$ROOT_DIR/docker/staging_pull_compose.template.yaml" '${DEVFRONT_PORT:-5174}:5174' +assert_contains "$ROOT_DIR/deploy/templates/docker-compose.yaml" '${DEVFRONT_PORT}:5174' + +assert_contains "$ROOT_DIR/docker/staging_pull_compose.template.yaml" "fetch('http://127.0.0.1:5174/')" +assert_contains "$ROOT_DIR/deploy/templates/docker-compose.yaml" "traefik.http.services.\${COMPOSE_PROJECT_NAME}-devfront.loadbalancer.server.port=5174" + +echo "OK: DevFront Docker internal port is aligned to 5174" diff --git a/test/prod_deploy_traefik_policy_test.sh b/test/prod_deploy_traefik_policy_test.sh new file mode 100644 index 00000000..6eb48fc9 --- /dev/null +++ b/test/prod_deploy_traefik_policy_test.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +compose_template="$repo_root/deploy/templates/docker-compose.yaml" +env_template="$repo_root/deploy/templates/.env.template" +create_script="$repo_root/deploy/create-instance.sh" + +fail() { + echo "$1" >&2 + exit 1 +} + +[[ -f "$compose_template" ]] || fail "deploy/templates/docker-compose.yaml must exist." +[[ -f "$env_template" ]] || fail "deploy/templates/.env.template must exist." +[[ -f "$create_script" ]] || fail "deploy/create-instance.sh must exist." + +grep -Fq "TRAEFIK_PUBLIC_NETWORK=traefik-public" "$env_template" \ + || fail ".env template must define TRAEFIK_PUBLIC_NETWORK=traefik-public." +grep -Fq "TRAEFIK_ENTRYPOINT=websecure" "$env_template" \ + || fail ".env template must define TRAEFIK_ENTRYPOINT=websecure." +grep -Fq "TRAEFIK_CERT_RESOLVER=myresolver" "$env_template" \ + || fail ".env template must define TRAEFIK_CERT_RESOLVER=myresolver." +grep -Fq "SOURCE_ROOT=../.." "$env_template" \ + || fail ".env template must define SOURCE_ROOT for compose build context." + +grep -Fq "traefik.enable=true" "$compose_template" \ + || fail "gateway must enable Traefik docker provider labels." +grep -Fq 'traefik.http.routers.${COMPOSE_PROJECT_NAME}-gateway.rule=Host(`${PUBLIC_HOST}`)' "$compose_template" \ + || fail "gateway must route the public host through Traefik." +grep -Fq 'traefik.http.routers.${COMPOSE_PROJECT_NAME}-gateway.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure}' "$compose_template" \ + || fail "gateway router must use the configured Traefik entrypoint." +grep -Fq 'traefik.http.routers.${COMPOSE_PROJECT_NAME}-gateway.tls.certresolver=${TRAEFIK_CERT_RESOLVER:-myresolver}' "$compose_template" \ + || fail "gateway router must configure the TLS cert resolver." +grep -Fq 'traefik.http.services.${COMPOSE_PROJECT_NAME}-gateway.loadbalancer.server.port=80' "$compose_template" \ + || fail "gateway service must expose internal port 80 to Traefik." +grep -Fq 'traefik.docker.network=${TRAEFIK_PUBLIC_NETWORK:-traefik-public}' "$compose_template" \ + || fail "gateway must pin Traefik to the public network." +grep -Fq 'context: ${SOURCE_ROOT:-../..}' "$compose_template" \ + || fail "public front builds must use SOURCE_ROOT so TARGET_DIR can live outside the repo." +grep -Fq '${SOURCE_ROOT:-../..}/adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro' "$compose_template" \ + || fail "backend seed volume must use SOURCE_ROOT so TARGET_DIR can live outside the repo." + +grep -Eq 'gateway:[[:space:]]*$' "$compose_template" \ + || fail "gateway service must exist." +grep -Fq -- "- traefik_public" "$compose_template" \ + || fail "public-facing services must join the external Traefik public network." +grep -Fq 'external: true' "$compose_template" \ + || fail "Traefik public network must be declared as external." +grep -Fq 'name: ${TRAEFIK_PUBLIC_NETWORK:-traefik-public}' "$compose_template" \ + || fail "Traefik public network name must be configurable." + +for service in adminfront devfront orgfront; do + grep -Fq "traefik.http.routers.\${COMPOSE_PROJECT_NAME}-${service}.rule=Host(" "$compose_template" \ + || fail "$service must expose a Traefik host router." + grep -Fq "traefik.http.services.\${COMPOSE_PROJECT_NAME}-${service}.loadbalancer.server.port=" "$compose_template" \ + || fail "$service must declare its internal Traefik service port." +done + +grep -Fq 'TRAEFIK_PUBLIC_NETWORK' "$create_script" \ + || fail "create-instance must know about the Traefik public network." +grep -Fq 'docker network create "$TRAEFIK_PUBLIC_NETWORK"' "$create_script" \ + || fail "create-instance must create the Traefik public network when missing." + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +cp "$compose_template" "$tmp_dir/docker-compose.yaml" +cp "$env_template" "$tmp_dir/.env" +sed -i 's/{{INSTANCE_NAME}}/policy/g; s/{{PORT_PREFIX}}/26/g' "$tmp_dir/.env" +docker compose --env-file "$tmp_dir/.env" -f "$tmp_dir/docker-compose.yaml" config >/dev/null diff --git a/test/production_image_workflows_policy_test.sh b/test/production_image_workflows_policy_test.sh new file mode 100644 index 00000000..e92c4d2e --- /dev/null +++ b/test/production_image_workflows_policy_test.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +publish_workflow="$repo_root/.gitea/workflows/production_image_publish.yml" +staging_deploy_workflow="$repo_root/.gitea/workflows/staging_image_deploy.yml" +deploy_workflow="$repo_root/.gitea/workflows/production_image_deploy.yml" +image_compose="$repo_root/deploy/templates/docker-compose.images.yaml" +bundle_script="$repo_root/scripts/deploy/build_image_deploy_bundle.sh" +remote_deploy_script="$repo_root/scripts/deploy/upload_and_run_image_deploy.sh" + +fail() { + echo "$1" >&2 + exit 1 +} + +[[ -f "$publish_workflow" ]] || fail "production image publish workflow must exist." +[[ -f "$staging_deploy_workflow" ]] || fail "staging image deploy workflow must exist." +[[ -f "$deploy_workflow" ]] || fail "production image deploy workflow must exist." +[[ -f "$image_compose" ]] || fail "image-based production compose template must exist." +[[ -f "$bundle_script" ]] || fail "shared image deployment bundle script must exist." +[[ -f "$remote_deploy_script" ]] || fail "shared image remote deploy script must exist." + +grep -Fq "name: Publish Baron SSO Production Images" "$publish_workflow" \ + || fail "publish workflow must have the expected name." +grep -Fq "workflow_dispatch:" "$publish_workflow" \ + || fail "publish workflow must be manually dispatchable." +if grep -Fq "source_ref:" "$publish_workflow"; then + fail "publish workflow must not accept an arbitrary source_ref; production images must be built from dev." +fi +grep -Fq "ref: dev" "$publish_workflow" \ + || fail "publish workflow must checkout the dev branch." +grep -Fq "version_prefix:" "$publish_workflow" \ + || fail "publish workflow must accept a version prefix." +grep -Fq 'git rev-parse --short=4 HEAD' "$publish_workflow" \ + || fail "publish workflow must derive the final image tag from the checked-out commit hash." +grep -Fq 'image_tag="${VERSION_PREFIX}.${short_sha}"' "$publish_workflow" \ + || fail "publish workflow must append the 4-character commit hash as the last version segment." +grep -Fq "steps.version.outputs.image_tag" "$publish_workflow" \ + || fail "publish workflow must use the computed image tag for pushed images." +grep -Fq "Upload pushed images to WORKS Drive archive" "$publish_workflow" \ + || fail "publish workflow must archive the exact pushed images to WORKS Drive." +grep -Fq "docker pull" "$publish_workflow" \ + || fail "publish workflow must pull the pushed image before WORKS archive upload." +grep -Fq "scripts/docker-image/upload_works_drive.sh" "$publish_workflow" \ + || fail "publish workflow must use the shared WORKS Drive image archive script." +grep -Fq "docker/login-action@v3" "$publish_workflow" \ + || fail "publish workflow must login to the shared registry." +grep -Fq "docker/build-push-action@v5" "$publish_workflow" \ + || fail "publish workflow must build and push images." +for image in backend userfront adminfront devfront orgfront; do + grep -Fq "/baron_sso/${image}:" "$publish_workflow" \ + || fail "publish workflow must push ${image} image." +done + +grep -Fq "name: Deploy Baron SSO Staging Images" "$staging_deploy_workflow" \ + || fail "staging deploy workflow must have the expected name." +grep -Fq "image_tag:" "$staging_deploy_workflow" \ + || fail "staging deploy workflow must accept the same immutable image tag as production." +grep -Fq "IMAGE_TAG must look like vX.YYMM.ab12" "$bundle_script" \ + || fail "shared bundle script must validate the commit-hash image tag format." +grep -Fq "IMAGE_DEPLOY_ENV: stage" "$staging_deploy_workflow" \ + || fail "staging deploy workflow must select the stage deployment environment." +grep -Fq "deploy/templates/docker-compose.images.yaml" "$staging_deploy_workflow" \ + || fail "staging deploy workflow must pass the image-based compose template through the shared bundle script." +grep -Fq "scripts/deploy/build_image_deploy_bundle.sh" "$staging_deploy_workflow" \ + || fail "staging deploy workflow must use the shared bundle script." +grep -Fq "scripts/deploy/upload_and_run_image_deploy.sh" "$staging_deploy_workflow" \ + || fail "staging deploy workflow must use the shared remote deploy script." + +grep -Fq "name: Deploy Baron SSO Production Images" "$deploy_workflow" \ + || fail "deploy workflow must have the expected name." +grep -Fq "image_tag:" "$deploy_workflow" \ + || fail "deploy workflow must accept an image tag." +grep -Fq "v1.2606.ab12" "$deploy_workflow" \ + || fail "deploy workflow must document the commit-hash image tag format with an example." +grep -Fq "IMAGE_TAG must look like vX.YYMM.ab12" "$bundle_script" \ + || fail "deploy workflow must rely on the shared commit-hash image tag validation." +grep -Fq "IMAGE_DEPLOY_ENV: production" "$deploy_workflow" \ + || fail "deploy workflow must select the production deployment environment." +grep -Fq 'APP_ENV=${app_env}' "$bundle_script" \ + || fail "shared bundle script must write APP_ENV from the selected deployment environment." +grep -Fq "deploy/templates/docker-compose.images.yaml" "$deploy_workflow" \ + || fail "deploy workflow must pass the image-based compose template through the shared bundle script." +grep -Fq "scripts/deploy/build_image_deploy_bundle.sh" "$deploy_workflow" \ + || fail "production deploy workflow must use the shared bundle script." +grep -Fq "scripts/deploy/upload_and_run_image_deploy.sh" "$deploy_workflow" \ + || fail "production deploy workflow must use the shared remote deploy script." +grep -Fq "Same image tag contract as staging" "$deploy_workflow" \ + || fail "production deploy workflow must document that it uses the same image tag as staging." +grep -Fq "TRAEFIK_PUBLIC_NETWORK=traefik-public" "$bundle_script" \ + || fail "shared bundle script must write Traefik public network env." +grep -Fq "docker compose --env-file .env -f docker-compose.yml pull" "$remote_deploy_script" \ + || fail "shared remote deploy script must pull the requested image version before running." +grep -Fq "docker compose --env-file .env -f docker-compose.yml up -d" "$remote_deploy_script" \ + || fail "shared remote deploy script must start the stack after pulling images." + +if grep -Eq 'docker (build|commit)' "$staging_deploy_workflow" "$deploy_workflow"; then + fail "staging/production deploy workflows must never build or commit images remotely." +fi + +if grep -Eq '^[[:space:]]+build:' "$image_compose"; then + fail "image-based production compose template must not contain build sections." +fi +for image_var in BACKEND_IMAGE_NAME USERFRONT_IMAGE_NAME ADMINFRONT_IMAGE_NAME DEVFRONT_IMAGE_NAME ORGFRONT_IMAGE_NAME; do + grep -Fq "\${${image_var}}:\${IMAGE_TAG}" "$image_compose" \ + || fail "image compose must use ${image_var} with IMAGE_TAG." +done +grep -Fq "APP_ENV=\${APP_ENV:-production}" "$image_compose" \ + || fail "image compose must run services with production APP_ENV default." +grep -Fq "traefik_public:" "$image_compose" \ + || fail "image compose must keep Traefik public network wiring." + +tmp_dir="$(mktemp -d)" +trap 'rm -rf "$tmp_dir"' EXIT +cp "$repo_root/deploy/templates/.env.template" "$tmp_dir/.env" +cp "$image_compose" "$tmp_dir/docker-compose.yml" +sed -i 's/{{INSTANCE_NAME}}/policy/g; s/{{PORT_PREFIX}}/26/g' "$tmp_dir/.env" + +IMAGE_TAG=v9.9999.ab12 \ +BACKEND_IMAGE_NAME=registry.example/baron_sso/backend \ +USERFRONT_IMAGE_NAME=registry.example/baron_sso/userfront \ +ADMINFRONT_IMAGE_NAME=registry.example/baron_sso/adminfront \ +DEVFRONT_IMAGE_NAME=registry.example/baron_sso/devfront \ +ORGFRONT_IMAGE_NAME=registry.example/baron_sso/orgfront \ +docker compose --env-file "$tmp_dir/.env" -f "$tmp_dir/docker-compose.yml" config >/dev/null diff --git a/test/traefik_forward_auth_config_policy_test.sh b/test/traefik_forward_auth_config_policy_test.sh new file mode 100644 index 00000000..3436a51e --- /dev/null +++ b/test/traefik_forward_auth_config_policy_test.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +compose_file="$repo_root/config/traefik-compose.yml" + +fail() { + echo "$1" >&2 + exit 1 +} + +[[ -f "$compose_file" ]] || fail "config/traefik-compose.yml must exist." + +if grep -Eq 'thomseddon/traefik-forward-auth:2\.3\.0' "$compose_file"; then + fail "traefik-forward-auth image tag 2.3.0 is unavailable; use a runnable pinned tag." +fi + +if grep -Eq 'auth/realms/master|PROVIDER_GENERIC_' "$compose_file"; then + fail "Traefik forward-auth must use Baron/Ory Hydra endpoint variables, not legacy Keycloak or unsupported provider keys." +fi + +if grep -Eq 'CLIENT_SECRET=[^$]|SECRET=[^$]' "$compose_file"; then + fail "Traefik forward-auth secrets must be injected from environment variables, not hardcoded in compose." +fi + +grep -Fq 'DEFAULT_PROVIDER=generic-oauth' "$compose_file" \ + || fail "Traefik bootstrap must use generic-oauth provider to avoid OIDC discovery before Baron/Ory is running." +grep -Fq 'PROVIDERS_GENERIC_OAUTH_AUTH_URL=${HYDRA_PUBLIC_URL:-https://app.brsw.kr/oidc}/oauth2/auth' "$compose_file" \ + || fail "Traefik forward-auth auth URL must point to Hydra authorize endpoint." +grep -Fq 'PROVIDERS_GENERIC_OAUTH_TOKEN_URL=${HYDRA_PUBLIC_URL:-https://app.brsw.kr/oidc}/oauth2/token' "$compose_file" \ + || fail "Traefik forward-auth token URL must point to Hydra token endpoint." +grep -Fq 'PROVIDERS_GENERIC_OAUTH_USER_URL=${HYDRA_PUBLIC_URL:-https://app.brsw.kr/oidc}/userinfo' "$compose_file" \ + || fail "Traefik forward-auth user URL must point to Hydra userinfo endpoint." + +grep -Fq 'traefik.http.routers.traefik-dashboard.middlewares=auth-forward@docker' "$compose_file" \ + || fail "Traefik dashboard router must be protected by auth-forward middleware." +grep -Fq 'traefik.http.services.forward-auth.loadbalancer.server.port=4181' "$compose_file" \ + || fail "forward-auth service port must be declared for Traefik docker provider." +grep -Fq 'traefik-public:' "$compose_file" \ + || fail "traefik-public external network must be declared." +grep -Fq 'name: traefik-public' "$compose_file" \ + || fail "traefik-public network name must be explicit." + +TRAEFIK_FORWARD_AUTH_CLIENT_SECRET=dummy \ +TRAEFIK_FORWARD_AUTH_COOKIE_SECRET=dummy \ +docker compose -f "$compose_file" config >/dev/null diff --git a/test/works_drive_docker_image_upload_policy_test.sh b/test/works_drive_docker_image_upload_policy_test.sh new file mode 100755 index 00000000..550671e2 --- /dev/null +++ b/test/works_drive_docker_image_upload_policy_test.sh @@ -0,0 +1,220 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +script="$repo_root/scripts/docker-image/upload_works_drive.sh" +doc="$repo_root/docs/works-drive-docker-image-archive.md" +makefile="$repo_root/Makefile" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +[[ -f "$script" ]] || fail "WORKS Drive Docker image upload script must exist." +[[ -f "$doc" ]] || fail "WORKS Drive Docker image archive design document must exist." + +grep -Fq 'WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR:-docker-build-image' "$script" \ + || fail "script must default WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR to docker-build-image." +grep -Fq 'docker commit' "$script" \ + || fail "script must support committing a local container before image export." +grep -Fq 'docker save' "$script" \ + || fail "script must use docker save for CLI-compatible image artifacts." +grep -Fq 'zstd' "$script" \ + || fail "script must compress Docker image archives with zstd." +grep -Fq 'manifest.json' "$script" \ + || fail "script must write a manifest.json next to the image archive." +grep -Fq 'image.tar.zst.sha256' "$script" \ + || fail "script must write a checksum file for the compressed image archive." +grep -Fq 'docker-build-image/baron_sso/backend/v1.2606.ab12' "$doc" \ + || fail "document must describe the expected WORKS Drive folder layout." +grep -Fq 'debian:trixie-slim' "$doc" \ + || fail "document must use debian:trixie-slim for smoke image examples." +grep -Fq 'staging과 production은 같은 image_tag' "$doc" \ + || fail "document must state that staging and production consume the same image tag." +grep -Fq 'docker-image-upload-works:' "$makefile" \ + || fail "Makefile must expose a docker-image-upload-works target." +grep -Fq 'scripts/docker-image/upload_works_drive.sh' "$makefile" \ + || fail "Makefile target must call the WORKS Drive image upload script." +if grep -Eq 'docker (push|pull)' "$script"; then + fail "WORKS Drive image archive script must not pretend to be a Docker Registry push/pull backend." +fi + +tmp_dir="$(mktemp -d /tmp/baron-sso-works-image-test.XXXXXX)" +trap 'rm -rf "$tmp_dir"' EXIT INT TERM + +fake_bin="$tmp_dir/bin" +mkdir -p "$fake_bin" + +cat >"$fake_bin/docker" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf 'docker %s\n' "$*" >>"${FAKE_DOCKER_LOG}" + +if [[ "$1" == "commit" ]]; then + printf 'sha256:committed-image\n' + exit 0 +fi + +if [[ "$1" == "image" && "$2" == "inspect" ]]; then + printf 'sha256:inspect-image-id\n' + exit 0 +fi + +if [[ "$1" == "save" ]]; then + output="" + image_ref="" + shift + while [[ "$#" -gt 0 ]]; do + case "$1" in + -o) + output="$2" + shift 2 + ;; + *) + image_ref="$1" + shift + ;; + esac + done + [[ -n "$output" ]] || exit 2 + printf 'docker image archive for %s\n' "$image_ref" >"$output" + exit 0 +fi + +echo "unexpected docker command: $*" >&2 +exit 2 +EOF +chmod +x "$fake_bin/docker" + +cat >"$fake_bin/zstd" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +output="" +input="" +while [[ "$#" -gt 0 ]]; do + case "$1" in + -o) + output="$2" + shift 2 + ;; + -*) + shift + ;; + *) + input="$1" + shift + ;; + esac +done +[[ -n "$output" && -n "$input" ]] || exit 2 +cp "$input" "$output" +EOF +chmod +x "$fake_bin/zstd" + +fake_curl="$tmp_dir/fake-curl.sh" +cat >"$fake_curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"${FAKE_CURL_LOG}" +last_arg="${!#}" + +case "$last_arg" in + https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/root-folder/children) + printf '{"files":[]}' + ;; + https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/root-folder/createfolder) + printf '{"fileId":"docker-build-image-id","fileName":"docker-build-image","fileType":"FOLDER"}' + ;; + https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/docker-build-image-id/children) + printf '{"files":[]}' + ;; + https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/docker-build-image-id/createfolder) + printf '{"fileId":"baron-sso-id","fileName":"baron_sso","fileType":"FOLDER"}' + ;; + https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/baron-sso-id/children) + printf '{"files":[]}' + ;; + https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/baron-sso-id/createfolder) + printf '{"fileId":"backend-id","fileName":"backend","fileType":"FOLDER"}' + ;; + https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/backend-id/children) + printf '{"files":[]}' + ;; + https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/backend-id/createfolder) + printf '{"fileId":"tag-id","fileName":"v1.2606.ab12","fileType":"FOLDER"}' + ;; + https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/tag-id) + printf '{"uploadUrl":"https://upload.example.test/docker-image"}' + ;; + https://upload.example.test/docker-image) + printf '{"fileId":"uploaded-file-id"}' + ;; + *) + echo "unexpected curl URL: $last_arg" >&2 + exit 2 + ;; +esac +EOF +chmod +x "$fake_curl" + +docker_log="$tmp_dir/docker.log" +curl_log="$tmp_dir/curl.log" +archive_dir="$tmp_dir/archive" + +FAKE_DOCKER_LOG="$docker_log" \ +FAKE_CURL_LOG="$curl_log" \ +PATH="$fake_bin:$PATH" \ +WORKS_DRIVE_ACCESS_TOKEN="test-access-token" \ +WORKS_DRIVE_TARGET="sharedrive" \ +WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \ +WORKS_DRIVE_PARENT_FILE_ID="root-folder" \ +WORKS_DRIVE_CURL_BIN="$fake_curl" \ +WORKS_DOCKER_IMAGE_ARCHIVE_DIR="$archive_dir" \ +WORKS_DOCKER_COMMIT_CONTAINER="baron_backend" \ +DOCKER_IMAGE_REF="registry.example/baron_sso/backend:v1.2606.ab12" \ +"$script" >"$tmp_dir/upload.out" + +artifact_dir="$archive_dir/baron_sso/backend/v1.2606.ab12" +[[ -f "$artifact_dir/image.tar.zst" ]] || fail "script must create image.tar.zst." +[[ -f "$artifact_dir/image.tar.zst.sha256" ]] || fail "script must create image.tar.zst.sha256." +[[ -f "$artifact_dir/manifest.json" ]] || fail "script must create manifest.json." + +jq -e \ + '.schema_version == 1 + and .format == "docker-save-zstd" + and .image_ref == "registry.example/baron_sso/backend:v1.2606.ab12" + and .repository == "baron_sso/backend" + and .tag == "v1.2606.ab12" + and .remote_path == "docker-build-image/baron_sso/backend/v1.2606.ab12" + and .archive.file_name == "image.tar.zst" + and (.archive.sha256 | type == "string")' \ + "$artifact_dir/manifest.json" >/dev/null || fail "manifest must describe the image archive and remote path." + +grep -Fq "docker commit baron_backend registry.example/baron_sso/backend:v1.2606.ab12" "$docker_log" \ + || fail "script must commit the requested container into the requested image ref." +grep -Fq "docker save -o" "$docker_log" \ + || fail "script must save the requested image." +grep -Fq "sharedrives/shared-drive-1/files/root-folder/createfolder" "$curl_log" \ + || fail "script must create the top-level docker-build-image folder when needed." +grep -Fq "docker-build-image" "$curl_log" \ + || fail "script must use WORKS_SHAREDRIVE_DOCKER_IMAGE_DIR in folder creation." +grep -Fq "baron_sso" "$curl_log" \ + || fail "script must create repository namespace folder." +grep -Fq "backend" "$curl_log" \ + || fail "script must create image repository folder." +grep -Fq "v1.2606.ab12" "$curl_log" \ + || fail "script must create tag folder." +grep -Fq "image.tar.zst" "$curl_log" \ + || fail "script must upload the compressed image archive." +grep -Fq "image.tar.zst.sha256" "$curl_log" \ + || fail "script must upload the checksum file." +grep -Fq "manifest.json" "$curl_log" \ + || fail "script must upload the manifest file." + +report_file="$artifact_dir/works-upload.json" +[[ -f "$report_file" ]] || fail "script must write works-upload.json." +jq -e '.status == "uploaded" and (.files | length) == 3' "$report_file" >/dev/null \ + || fail "upload report must include three uploaded artifact files." + +echo "OK: WORKS Drive Docker image archive upload flow commits, packages, and uploads image artifacts" diff --git a/test/works_drive_refresh_token_policy_test.sh b/test/works_drive_refresh_token_policy_test.sh new file mode 100755 index 00000000..9d7da0cf --- /dev/null +++ b/test/works_drive_refresh_token_policy_test.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail() { + echo "ERROR: $*" >&2 + exit 1 +} + +script="$repo_root/scripts/backup/refresh_works_drive_token.sh" +[[ -f "$script" ]] || fail "refresh_works_drive_token.sh must exist." + +tmp_dir="$(mktemp -d /tmp/baron-sso-works-drive-token-test.XXXXXX)" +trap 'rm -rf "$tmp_dir"' EXIT INT TERM + +env_file="$tmp_dir/.env" +cat >"$env_file" <<'EOF' +WORKS_DRIVE_OAUTH_CLIENT_ID=client-id-1 +WORKS_DRIVE_OAUTH_CLIENT_SECRET=client-secret-1 +WORKS_DRIVE_OAUTH_REDIRECT_URI=https://example.test/callback +WORKS_DRIVE_OAUTH_REFRESH_TOKEN=old-refresh-token +WORKS_DRIVE_AUTH_MODE=auto +EOF +chmod 600 "$env_file" + +curl_log="$tmp_dir/curl.log" +fake_curl="$tmp_dir/fake-curl.sh" +cat >"$fake_curl" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"${FAKE_CURL_LOG}" + +if [[ "$*" == *"grant_type=refresh_token"* ]]; then + printf '{"access_token":"new-access-token","refresh_token":"new-refresh-token","expires_in":"86400","token_type":"Bearer","scope":"file"}' + exit 0 +fi + +if [[ "$*" == *"grant_type=authorization_code"* ]]; then + printf '{"access_token":"code-access-token","refresh_token":"code-refresh-token","expires_in":"86400","token_type":"Bearer","scope":"file"}' + exit 0 +fi + +echo "unexpected curl arguments: $*" >&2 +exit 2 +EOF +chmod +x "$fake_curl" + +WORKS_DRIVE_ENV_FILE="$env_file" \ +WORKS_DRIVE_CURL_BIN="$fake_curl" \ +FAKE_CURL_LOG="$curl_log" \ +"$script" >"$tmp_dir/refresh.out" + +grep -Fq "WORKS Drive refresh token updated" "$tmp_dir/refresh.out" || fail "refresh-token mode must update the refresh token." +grep -Fq "grant_type=refresh_token" "$curl_log" || fail "refresh-token mode must call refresh_token grant." +grep -Fq "WORKS_DRIVE_OAUTH_REFRESH_TOKEN=new-refresh-token" "$env_file" || fail ".env must contain the rotated refresh token." +grep -Fq "WORKS_DRIVE_AUTH_MODE=refresh-token" "$env_file" || fail ".env must prefer refresh-token mode after token refresh." +[[ "$(stat -c '%a' "$env_file")" == "600" ]] || fail ".env mode must be preserved after refresh token update." +if grep -Fq "new-access-token" "$env_file"; then + fail "short-lived access token must not be persisted by default." +fi + +auth_env_file="$tmp_dir/.env.auth-code" +cat >"$auth_env_file" <<'EOF' +WORKS_DRIVE_OAUTH_CLIENT_ID=client-id-1 +WORKS_DRIVE_OAUTH_CLIENT_SECRET=client-secret-1 +WORKS_DRIVE_OAUTH_REDIRECT_URI=https://example.test/callback +EOF + +WORKS_DRIVE_ENV_FILE="$auth_env_file" \ +WORKS_DRIVE_CURL_BIN="$fake_curl" \ +WORKS_DRIVE_TOKEN_GRANT=authorization-code \ +WORKS_DRIVE_AUTH_CALLBACK_URL="https://example.test/callback?code=auth-code-1&state=state-1" \ +FAKE_CURL_LOG="$curl_log" \ +"$script" >"$tmp_dir/auth-code.out" + +grep -Fq "WORKS Drive refresh token updated" "$tmp_dir/auth-code.out" || fail "authorization-code mode must update the refresh token." +grep -Fq "grant_type=authorization_code" "$curl_log" || fail "authorization-code mode must call authorization_code grant." +grep -Fq "code=auth-code-1" "$curl_log" || fail "authorization-code mode must extract code from callback URL." +grep -Fq "WORKS_DRIVE_OAUTH_REFRESH_TOKEN=code-refresh-token" "$auth_env_file" || fail ".env must contain authorization-code refresh token." + +authorize_url="$( + WORKS_DRIVE_ENV_FILE="$auth_env_file" \ + WORKS_DRIVE_TOKEN_GRANT=print-authorize-url \ + "$script" +)" + +grep -Fq "https://auth.worksmobile.com/oauth2/v2.0/authorize" <<<"$authorize_url" || fail "print-authorize-url mode must print WORKS authorize URL." +grep -Fq "client_id=client-id-1" <<<"$authorize_url" || fail "authorize URL must include client_id." +grep -Fq "response_type=code" <<<"$authorize_url" || fail "authorize URL must request an authorization code." + +make_dry_run="$( + make --dry-run --always-make -C "$repo_root" works-drive-refresh-token WORKS_DRIVE_TOKEN_GRANT=refresh-token 2>&1 +)" + +grep -Fq "scripts/backup/refresh_works_drive_token.sh" <<<"$make_dry_run" || fail "Makefile target must call refresh token script." +grep -Fq "WORKS_DRIVE_TOKEN_GRANT=\"refresh-token\"" <<<"$make_dry_run" || fail "Makefile target must pass token grant." +if grep -Fq "docker run" <<<"$make_dry_run"; then + fail "Makefile target must refresh .env on the host, not inside Docker." +fi + +echo "OK: WORKS Drive refresh token helper updates env and supports authorization-code bootstrap"