forked from baron/baron-sso
production 푸시 초안
This commit is contained in:
96
.gitea/workflows/production_image_deploy.yml
Normal file
96
.gitea/workflows/production_image_deploy.yml
Normal file
@@ -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
|
||||
182
.gitea/workflows/production_image_publish.yml
Normal file
182
.gitea/workflows/production_image_publish.yml
Normal file
@@ -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
|
||||
94
.gitea/workflows/staging_image_deploy.yml
Normal file
94
.gitea/workflows/staging_image_deploy.yml
Normal file
@@ -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
|
||||
18
Makefile
18
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 <target> [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
|
||||
|
||||
@@ -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(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/permissions-direct"
|
||||
element={<TenantFineGrainedPermissionsPage />}
|
||||
/>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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() {
|
||||
<h2 className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_title",
|
||||
"Super Admin 역할 회수",
|
||||
"Super Admin 역할 관리",
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.super_admin_description",
|
||||
"현재 로그인한 관리자와 최초 관리자를 제외한 Super Admin 역할을 회수합니다.",
|
||||
"사용자를 검색해 Super Admin 역할을 부여하고, 현재 로그인한 관리자와 최초 관리자를 제외한 기존 역할은 회수합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-border/70 bg-muted/10 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_users",
|
||||
"대상 사용자",
|
||||
)}
|
||||
</h3>
|
||||
<Badge variant="secondary">
|
||||
{selectedSuperAdminUserIds.length}
|
||||
{t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
|
||||
{superAdminUsersQuery.isFetching ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t("msg.common.loading", "불러오는 중입니다.")}
|
||||
</div>
|
||||
) : revocableSuperAdminUsers.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="rounded-lg border border-border/70 bg-muted/10 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.no_users_found",
|
||||
"등록된 사용자가 없습니다.",
|
||||
"ui.admin.permissions_direct.super_admin_search",
|
||||
"사용자 검색",
|
||||
)}
|
||||
</h3>
|
||||
<Badge variant="outline">
|
||||
{superAdminGrantSearchResults.length}
|
||||
{t("ui.admin.permissions_direct.search_results", "건")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={superAdminGrantSearch}
|
||||
onChange={(event) =>
|
||||
setSuperAdminGrantSearch(event.target.value)
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.admin.permissions_direct.super_admin_search_placeholder",
|
||||
"UUID, 이름, 이메일, 전화번호 검색",
|
||||
)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 max-h-96 space-y-2 overflow-y-auto pr-1">
|
||||
{superAdminUsersQuery.isFetching ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t("msg.common.loading", "불러오는 중입니다.")}
|
||||
</div>
|
||||
) : !superAdminGrantSearch.trim() ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.super_admin_search_empty",
|
||||
"검색어를 입력하세요.",
|
||||
)}
|
||||
</div>
|
||||
) : superAdminGrantSearchResults.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.no_users_found",
|
||||
"등록된 사용자가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
superAdminGrantSearchResults.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 rounded-md border border-border/60 bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.phone || user.id}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => queueSuperAdminGrantUser(user)}
|
||||
>
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_grant_queue_add",
|
||||
"부여 대상 추가",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border/70 bg-muted/10 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_grant_targets",
|
||||
"부여 대상자",
|
||||
)}
|
||||
</h3>
|
||||
<Badge variant="secondary">
|
||||
{queuedSuperAdminGrantUsers.length}
|
||||
{t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto pr-1">
|
||||
{queuedSuperAdminGrantUsers.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.super_admin_grant_queue_empty",
|
||||
"부여할 사용자를 왼쪽 검색 결과에서 추가하세요.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
queuedSuperAdminGrantUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
data-testid={`super-admin-grant-target-${user.id}`}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t(
|
||||
"ui.admin.permissions_direct.super_admin_grant_queue_remove",
|
||||
"부여 대상 제거",
|
||||
)}
|
||||
onClick={() =>
|
||||
removeQueuedSuperAdminGrantUser(user.id)
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
revocableSuperAdminUsers.map((user) => (
|
||||
<label
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm hover:bg-muted/40"
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
onClick={handleGrantSuperAdminRole}
|
||||
disabled={
|
||||
updateUserRoleMutation.isPending ||
|
||||
queuedSuperAdminGrantUsers.length === 0
|
||||
}
|
||||
>
|
||||
<input
|
||||
name={`super-admin-role-user-${user.id}`}
|
||||
type="checkbox"
|
||||
data-testid={`super-admin-role-user-${user.id}`}
|
||||
checked={selectedSuperAdminUserIds.includes(user.id)}
|
||||
onChange={(event) =>
|
||||
toggleSuperAdminUser(user.id, event.target.checked)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_grant",
|
||||
"Super Admin 부여",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button
|
||||
onClick={handleRevokeSuperAdminRole}
|
||||
disabled={
|
||||
updateUserRoleMutation.isPending ||
|
||||
selectedSuperAdminUserIds.length === 0
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_revoke",
|
||||
"Super Admin 회수",
|
||||
)}
|
||||
</Button>
|
||||
<div className="rounded-lg border border-border/70 bg-muted/10 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_users",
|
||||
"현재 Super Admin",
|
||||
)}
|
||||
</h3>
|
||||
<Badge variant="secondary">
|
||||
{selectedSuperAdminUserIds.length}
|
||||
{t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
|
||||
{superAdminUsersQuery.isFetching ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t("msg.common.loading", "불러오는 중입니다.")}
|
||||
</div>
|
||||
) : revocableSuperAdminUsers.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.no_users_found",
|
||||
"등록된 사용자가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
revocableSuperAdminUsers.map((user) => (
|
||||
<label
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
name={`super-admin-role-user-${user.id}`}
|
||||
type="checkbox"
|
||||
data-testid={`super-admin-role-user-${user.id}`}
|
||||
checked={selectedSuperAdminUserIds.includes(user.id)}
|
||||
onChange={(event) =>
|
||||
toggleSuperAdminUser(user.id, event.target.checked)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
onClick={handleRevokeSuperAdminRole}
|
||||
disabled={
|
||||
updateUserRoleMutation.isPending ||
|
||||
selectedSuperAdminUserIds.length === 0
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_revoke",
|
||||
"Super Admin 회수",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
69
config/traefik-compose.yml
Normal file
69
config/traefik-compose.yml
Normal file
@@ -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
|
||||
@@ -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."
|
||||
|
||||
@@ -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.<commit4> 이미지 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/<repository-path>/<tag>/` 아래에 `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` 주소 등이 인스턴스 포트에 맞게 자동 치환됩니다.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
392
deploy/templates/docker-compose.images.yaml
Normal file
392
deploy/templates/docker-compose.images.yaml
Normal file
@@ -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}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<code>`에 전달한다.
|
||||
- callback URL 전체를 복사할 수 있으면 `WORKS_DRIVE_AUTH_CALLBACK_URL=<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을 별도로 수행해야 한다.
|
||||
|
||||
190
docs/traefik-production-rp-bootstrap-design.md
Normal file
190
docs/traefik-production-rp-bootstrap-design.md
Normal file
@@ -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": "<from env>",
|
||||
"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 <client_id>`로 존재 여부 확인
|
||||
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에 맞추는 것이다.
|
||||
121
docs/works-drive-docker-image-archive.md
Normal file
121
docs/works-drive-docker-image-archive.md
Normal file
@@ -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/<repository-path>/<tag>/
|
||||
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.<commit4> -> 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는 지원하지 않는다.
|
||||
243
scripts/backup/refresh_works_drive_token.sh
Executable file
243
scripts/backup/refresh_works_drive_token.sh
Executable file
@@ -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
|
||||
@@ -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() {
|
||||
|
||||
162
scripts/deploy/build_image_deploy_bundle.sh
Executable file
162
scripts/deploy/build_image_deploy_bundle.sh
Executable file
@@ -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" <<EOF
|
||||
INSTANCE_NAME=${instance_name}
|
||||
COMPOSE_PROJECT_NAME=baron-sso-${instance_name}
|
||||
APP_ENV=${app_env}
|
||||
TZ=Asia/Seoul
|
||||
SOURCE_ROOT=.
|
||||
P=${IMAGE_DEPLOY_PORT_PREFIX}
|
||||
DB_PORT=${IMAGE_DEPLOY_DB_PORT}
|
||||
REDIS_PORT=${IMAGE_DEPLOY_REDIS_PORT}
|
||||
CLICKHOUSE_PORT_HTTP=${IMAGE_DEPLOY_CLICKHOUSE_PORT_HTTP}
|
||||
CLICKHOUSE_PORT_NATIVE=${IMAGE_DEPLOY_CLICKHOUSE_PORT_NATIVE}
|
||||
BACKEND_PORT=${IMAGE_DEPLOY_BACKEND_PORT}
|
||||
USERFRONT_PORT=${IMAGE_DEPLOY_FRONTEND_PORT}
|
||||
ADMINFRONT_PORT=${ADMINFRONT_PORT}
|
||||
DEVFRONT_PORT=${DEVFRONT_PORT}
|
||||
ORGFRONT_PORT=${ORGFRONT_PORT}
|
||||
OATHKEEPER_PROXY_PORT=${IMAGE_DEPLOY_OATHKEEPER_PROXY_PORT}
|
||||
DOMAIN_SUFFIX=${IMAGE_DEPLOY_DOMAIN_SUFFIX}
|
||||
USERFRONT_URL=${IMAGE_DEPLOY_PUBLIC_URL}
|
||||
ADMINFRONT_URL=${ADMINFRONT_URL}
|
||||
DEVFRONT_URL=${DEVFRONT_URL}
|
||||
ORGFRONT_URL=${ORGFRONT_URL}
|
||||
PUBLIC_HOST=${public_host}
|
||||
ADMINFRONT_HOST=${admin_host}
|
||||
DEVFRONT_HOST=${dev_host}
|
||||
ORGFRONT_HOST=${org_host}
|
||||
TRAEFIK_PUBLIC_NETWORK=traefik-public
|
||||
TRAEFIK_ENTRYPOINT=websecure
|
||||
TRAEFIK_CERT_RESOLVER=myresolver
|
||||
VITE_OIDC_AUTHORITY=${VITE_OIDC_AUTHORITY}
|
||||
ADMINFRONT_CALLBACK_URLS=${ADMINFRONT_CALLBACK_URLS}
|
||||
DEVFRONT_CALLBACK_URLS=${DEVFRONT_CALLBACK_URLS}
|
||||
ORGFRONT_CALLBACK_URLS=${ORGFRONT_CALLBACK_URLS}
|
||||
KRATOS_UI_URL=${IMAGE_DEPLOY_PUBLIC_URL}/auth
|
||||
KRATOS_BROWSER_URL=${IMAGE_DEPLOY_PUBLIC_URL}/auth
|
||||
KRATOS_ADMIN_URL=http://kratos:4434
|
||||
HYDRA_PUBLIC_URL=${IMAGE_DEPLOY_PUBLIC_URL}/oidc
|
||||
HYDRA_ADMIN_URL=http://hydra:4445
|
||||
HYDRA_LOGIN_URL=${IMAGE_DEPLOY_PUBLIC_URL}/login
|
||||
HYDRA_CONSENT_URL=${IMAGE_DEPLOY_PUBLIC_URL}/consent
|
||||
HYDRA_ERROR_URL=${IMAGE_DEPLOY_PUBLIC_URL}/error
|
||||
HYDRA_REFRESH_TOKEN_TTL=${HYDRA_REFRESH_TOKEN_TTL}
|
||||
OATHKEEPER_PUBLIC_URL=${IMAGE_DEPLOY_PUBLIC_URL}
|
||||
KETO_READ_URL=http://keto:4466
|
||||
KETO_WRITE_URL=http://keto:4467
|
||||
IDP_PROVIDER=ory
|
||||
DB_PASSWORD=${IMAGE_DEPLOY_DB_PASSWORD}
|
||||
ORY_POSTGRES_USER=${ORY_POSTGRES_USER}
|
||||
ORY_POSTGRES_PASSWORD=${IMAGE_DEPLOY_ORY_POSTGRES_PASSWORD}
|
||||
ORY_POSTGRES_DB=${ORY_POSTGRES_DB}
|
||||
KRATOS_DB=${KRATOS_DB}
|
||||
HYDRA_DB=${HYDRA_DB}
|
||||
KETO_DB=${KETO_DB}
|
||||
KRATOS_VERSION=${KRATOS_VERSION}
|
||||
HYDRA_VERSION=${HYDRA_VERSION}
|
||||
KETO_VERSION=${KETO_VERSION}
|
||||
OATHKEEPER_VERSION=${OATHKEEPER_VERSION}
|
||||
ORY_POSTGRES_TAG=${ORY_POSTGRES_TAG}
|
||||
OATHKEEPER_UID=${OATHKEEPER_UID}
|
||||
OATHKEEPER_GID=${OATHKEEPER_GID}
|
||||
OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID}
|
||||
OATHKEEPER_INTROSPECT_CLIENT_SECRET=${IMAGE_DEPLOY_OATHKEEPER_INTROSPECT_CLIENT_SECRET}
|
||||
CLICKHOUSE_PASSWORD=${IMAGE_DEPLOY_CLICKHOUSE_PASSWORD}
|
||||
REDIS_ADDR=redis:6379
|
||||
COOKIE_SECRET=${IMAGE_DEPLOY_COOKIE_SECRET}
|
||||
JWT_SECRET=${IMAGE_DEPLOY_JWT_SECRET}
|
||||
CSRF_COOKIE_SECRET=${IMAGE_DEPLOY_CSRF_COOKIE_SECRET}
|
||||
ADMIN_EMAIL=${ADMIN_EMAIL}
|
||||
ADMIN_PASSWORD=${IMAGE_DEPLOY_ADMIN_PASSWORD}
|
||||
IMAGE_TAG=${IMAGE_TAG}
|
||||
BACKEND_IMAGE_NAME=${BACKEND_IMAGE_NAME}
|
||||
USERFRONT_IMAGE_NAME=${USERFRONT_IMAGE_NAME}
|
||||
ADMINFRONT_IMAGE_NAME=${ADMINFRONT_IMAGE_NAME}
|
||||
DEVFRONT_IMAGE_NAME=${DEVFRONT_IMAGE_NAME}
|
||||
ORGFRONT_IMAGE_NAME=${ORGFRONT_IMAGE_NAME}
|
||||
EOF
|
||||
|
||||
chmod 600 "$bundle_dir/.env"
|
||||
|
||||
required_dotenv_keys="
|
||||
APP_ENV IMAGE_TAG BACKEND_IMAGE_NAME USERFRONT_IMAGE_NAME ADMINFRONT_IMAGE_NAME DEVFRONT_IMAGE_NAME ORGFRONT_IMAGE_NAME
|
||||
USERFRONT_URL PUBLIC_HOST HYDRA_PUBLIC_URL VITE_OIDC_AUTHORITY TRAEFIK_PUBLIC_NETWORK
|
||||
DB_PASSWORD ORY_POSTGRES_PASSWORD COOKIE_SECRET JWT_SECRET CSRF_COOKIE_SECRET
|
||||
"
|
||||
for key in $required_dotenv_keys; do
|
||||
if ! grep -Eq "^${key}=.+" "$bundle_dir/.env"; then
|
||||
die "Missing required bundle .env value: $key"
|
||||
fi
|
||||
done
|
||||
|
||||
ORY_CONFIG_ENV_FILES="$bundle_dir/.env" \
|
||||
ORY_CONFIG_TEMPLATE_ROOT="$bundle_dir/ory/templates" \
|
||||
ORY_CONFIG_OUTPUT_DIR="$bundle_dir/config/.generated/ory" \
|
||||
bash "$repo_root/scripts/render_ory_config.sh"
|
||||
|
||||
tar -C "$bundle_dir" -czf "$bundle_file" .
|
||||
printf '%s\n' "$bundle_file"
|
||||
39
scripts/deploy/upload_and_run_image_deploy.sh
Executable file
39
scripts/deploy/upload_and_run_image_deploy.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
die() {
|
||||
printf 'ERROR: %s\n' "$*" >&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"
|
||||
632
scripts/docker-image/upload_works_drive.sh
Executable file
632
scripts/docker-image/upload_works_drive.sh
Executable file
@@ -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"
|
||||
@@ -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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
75
test/devfront_port_policy_test.sh
Normal file
75
test/devfront_port_policy_test.sh
Normal file
@@ -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"
|
||||
70
test/prod_deploy_traefik_policy_test.sh
Normal file
70
test/prod_deploy_traefik_policy_test.sh
Normal file
@@ -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
|
||||
126
test/production_image_workflows_policy_test.sh
Normal file
126
test/production_image_workflows_policy_test.sh
Normal file
@@ -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
|
||||
46
test/traefik_forward_auth_config_policy_test.sh
Normal file
46
test/traefik_forward_auth_config_policy_test.sh
Normal file
@@ -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
|
||||
220
test/works_drive_docker_image_upload_policy_test.sh
Executable file
220
test/works_drive_docker_image_upload_policy_test.sh
Executable file
@@ -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"
|
||||
102
test/works_drive_refresh_token_policy_test.sh
Executable file
102
test/works_drive_refresh_token_policy_test.sh
Executable file
@@ -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"
|
||||
Reference in New Issue
Block a user