1
0
forked from baron/baron-sso

production 푸시 초안

This commit is contained in:
2026-06-18 11:02:48 +09:00
parent 33249eb229
commit a56d68896f
37 changed files with 3573 additions and 114 deletions

View 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

View 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

View 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

View File

@@ -47,8 +47,11 @@ ifneq (,$(wildcard ./$(AUTH_CONFIG_ENV)))
BACKUP_DOCKER_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV) BACKUP_DOCKER_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
endif 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) 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: ## 생성된 타깃과 옵션 목록 표시 help: ## 생성된 타깃과 옵션 목록 표시
@printf "Usage:\n make <target> [OPTION=value ...]\n\n" @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' $(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 ## 백업 덤프 클라우드 업로드 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 else
dump: ## 백업 덤프 생성 dump: ## 백업 덤프 생성
DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh 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 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: ## 백업 덤프 클라우드 업로드 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 endif
dump-upload-cloud: dump upload-cloud ## 백업 덤프 생성 후 클라우드 업로드 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_BROWSERS_PATH := $(HOME)/.cache/ms-playwright
PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE PLAYWRIGHT_CHROMIUM_COMPLETE := $(PLAYWRIGHT_BROWSERS_PATH)/chromium-1208/INSTALLATION_COMPLETE

View File

@@ -82,6 +82,7 @@ describe("TenantFineGrainedPermissionsPage Super Admin role tab", () => {
id: "regular-user", id: "regular-user",
name: "Regular User", name: "Regular User",
email: "regular@example.com", email: "regular@example.com",
phone: "010-0000-0001",
role: "user", role: "user",
status: "active", status: "active",
createdAt: "2026-06-17T00:00:00Z", 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",
}),
);
});
}); });

View File

@@ -67,6 +67,20 @@ function isBootstrapSuperAdminUser(user: UserSummary) {
return user.metadata?.bootstrapSuperAdmin === true; 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() { export function TenantFineGrainedPermissionsPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -88,6 +102,10 @@ export function TenantFineGrainedPermissionsPage() {
const [selectedSuperAdminUserIds, setSelectedSuperAdminUserIds] = useState< const [selectedSuperAdminUserIds, setSelectedSuperAdminUserIds] = useState<
string[] string[]
>([]); >([]);
const [superAdminGrantSearch, setSuperAdminGrantSearch] = useState("");
const [queuedSuperAdminGrantUsers, setQueuedSuperAdminGrantUsers] = useState<
UserSummary[]
>([]);
const [assignmentSearchTerm, setAssignmentSearchTerm] = useState(""); const [assignmentSearchTerm, setAssignmentSearchTerm] = useState("");
const [assignmentSort, setAssignmentSort] = useState< const [assignmentSort, setAssignmentSort] = useState<
"user" | "relation" | "level" "user" | "relation" | "level"
@@ -149,6 +167,28 @@ export function TenantFineGrainedPermissionsPage() {
}); });
}, [profile?.email, profile?.id, superAdminUsersQuery.data?.items]); }, [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({ const tenantRelationsQuery = useQuery({
queryKey: ["tenant-relations", targetTenantId], queryKey: ["tenant-relations", targetTenantId],
queryFn: () => fetchTenantRelations(targetTenantId), queryFn: () => fetchTenantRelations(targetTenantId),
@@ -325,6 +365,10 @@ export function TenantFineGrainedPermissionsPage() {
), ),
); );
setSelectedSuperAdminUserIds([]); setSelectedSuperAdminUserIds([]);
if (variables.role === "super_admin") {
setQueuedSuperAdminGrantUsers([]);
setSuperAdminGrantSearch("");
}
}, },
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>) => {
toast.error( 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 = () => { const resolveBulkRelation = () => {
if (bulkRelationMode === "page") { if (bulkRelationMode === "page") {
return bulkPageRelation; 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[]) => { const queueTargetUsers = useCallback((users: UserSummary[]) => {
setQueuedTargetUsers((current) => { setQueuedTargetUsers((current) => {
const next = [...current]; const next = [...current];
@@ -1378,23 +1453,176 @@ export function TenantFineGrainedPermissionsPage() {
<h2 className="text-lg font-bold"> <h2 className="text-lg font-bold">
{t( {t(
"ui.admin.permissions_direct.super_admin_title", "ui.admin.permissions_direct.super_admin_title",
"Super Admin 역할 회수", "Super Admin 역할 관리",
)} )}
</h2> </h2>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{t( {t(
"msg.admin.permissions_direct.super_admin_description", "msg.admin.permissions_direct.super_admin_description",
"현재 로그인한 관리자와 최초 관리자를 제외한 Super Admin 역할 회수합니다.", "사용자를 검색해 Super Admin 역할을 부여하고, 현재 로그인한 관리자와 최초 관리자를 제외한 기존 역할 회수합니다.",
)} )}
</p> </p>
</div> </div>
<div className="mt-5 rounded-lg border border-border/70 bg-muted/10 p-4"> <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(
"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>
<div className="mt-4 flex justify-end">
<Button
onClick={handleGrantSuperAdminRole}
disabled={
updateUserRoleMutation.isPending ||
queuedSuperAdminGrantUsers.length === 0
}
>
{t(
"ui.admin.permissions_direct.super_admin_grant",
"Super Admin 부여",
)}
</Button>
</div>
</div>
<div className="rounded-lg border border-border/70 bg-muted/10 p-4">
<div className="mb-3 flex items-center justify-between gap-3"> <div className="mb-3 flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold"> <h3 className="text-sm font-semibold">
{t( {t(
"ui.admin.permissions_direct.super_admin_users", "ui.admin.permissions_direct.super_admin_users",
"대상 사용자", "현재 Super Admin",
)} )}
</h3> </h3>
<Badge variant="secondary"> <Badge variant="secondary">
@@ -1441,9 +1669,7 @@ export function TenantFineGrainedPermissionsPage() {
)) ))
)} )}
</div> </div>
</div> <div className="mt-4 flex justify-end">
<div className="mt-5 flex justify-end">
<Button <Button
onClick={handleRevokeSuperAdminRole} onClick={handleRevokeSuperAdminRole}
disabled={ disabled={
@@ -1458,6 +1684,9 @@ export function TenantFineGrainedPermissionsPage() {
</Button> </Button>
</div> </div>
</div> </div>
</div>
</div>
</div>
)} )}
</div> </div>
); );

View File

@@ -6,9 +6,6 @@ import { expect, test, type Route } from "@playwright/test";
const targetTenantId = const targetTenantId =
process.env.TENANT_PROFILE_PERF_TENANT_ID ?? process.env.TENANT_PROFILE_PERF_TENANT_ID ??
"56cd0fd7-b62a-43c0-8db9-74a30468d7cb"; "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"); const evidenceDir = path.resolve("e2e-evidence");
type ApiTiming = { 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) { function percentile(values: number[], ratio: number) {
const sorted = [...values].sort((left, right) => left - right); const sorted = [...values].sort((left, right) => left - right);
const index = Math.min( 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 ({ test("loads org config fields through the local API within 500ms", async ({
page, page,
}, testInfo) => { }, 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 }); fs.mkdirSync(evidenceDir, { recursive: true });
await page.setViewportSize({ width: 1440, height: 900 }); await page.setViewportSize({ width: 1440, height: 900 });

View 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

View File

@@ -1,7 +1,8 @@
#!/bin/bash #!/bin/bash
set -euo pipefail
# ================================================================= # =================================================================
# Baron SSO 인스턴스 자동 생성 스크립트 (Full Infrastructure) # Baron SSO 인스턴스 자동 생성 스크립트 (전체 인프라 포함)
# ================================================================= # =================================================================
if [ "$#" -ne 2 ]; then if [ "$#" -ne 2 ]; then
@@ -12,7 +13,8 @@ fi
INSTANCE_NAME=$1 INSTANCE_NAME=$1
PORT_PREFIX=$2 PORT_PREFIX=$2
BASE_DIR=$(cd $(dirname $0); pwd) 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)" echo "🚀 Creating instance: ${INSTANCE_NAME} (Port Prefix: ${PORT_PREFIX}xxx)"
@@ -32,6 +34,22 @@ mkdir -p "${TARGET_DIR}/orgfront"
# 2. .env 생성 및 변수 로드 # 2. .env 생성 및 변수 로드
sed "s/{{INSTANCE_NAME}}/${INSTANCE_NAME}/g; s/{{PORT_PREFIX}}/${PORT_PREFIX}/g" \ sed "s/{{INSTANCE_NAME}}/${INSTANCE_NAME}/g; s/{{PORT_PREFIX}}/${PORT_PREFIX}/g" \
"${BASE_DIR}/templates/.env.template" > "${TARGET_DIR}/.env" "${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" BACKEND_PORT="${PORT_PREFIX}000"
@@ -44,21 +62,21 @@ ORGFRONT_DOMAIN="${INSTANCE_NAME}-org.${DOMAIN_SUFFIX}"
# 3. Docker Compose & Config 복사 및 치환 # 3. Docker Compose & Config 복사 및 치환
cp "${BASE_DIR}/templates/docker-compose.yaml" "${TARGET_DIR}/" 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/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" 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" 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.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.prod.json"
cp "${TARGET_DIR}/ory/templates/oathkeeper/rules.json" "${TARGET_DIR}/ory/templates/oathkeeper/rules.active.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" \ 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" "${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" \ 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" "${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" \ 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" bash "${BASE_DIR}/../scripts/render_ory_config.sh"
# 6. 마무리 # 6. 마무리
chmod +x "${TARGET_DIR}/.env" chmod 600 "${TARGET_DIR}/.env"
echo "--------------------------------------------------" echo "--------------------------------------------------"
echo "✅ Success! ALL files (Infra/Ory/Apps/FrontConfigs) are ready." echo "✅ Success! ALL files (Infra/Ory/Apps/FrontConfigs) are ready."

View File

@@ -43,12 +43,64 @@ cd instances/test3
docker compose up -d 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 규칙 ## 4. 도메인 및 URL 규칙
스크립트 실행 시 인스턴스 이름에 따라 다음 도메인이 자동으로 설정 파일에 주입됩니다. (`DOMAIN_SUFFIX``.env`에서 수정 가능) 스크립트 실행 시 인스턴스 이름에 따라 다음 도메인이 자동으로 설정 파일에 주입됩니다. (`DOMAIN_SUFFIX``.env`에서 수정 가능)
- **SSO/UserFront**: `https://[이름]-sso.hmac.kr` - **SSO/UserFront**: `https://[이름]-sso.hmac.kr`
- **AdminFront**: `https://[이름]-admin.hmac.kr` - **AdminFront**: `https://[이름]-admin.hmac.kr`
- **DevFront**: `https://[이름]-dev.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. 주요 설정 파일 관리 ## 5. 주요 설정 파일 관리
- **Nginx (Gateway & UserFront)**: 각 인스턴스의 백엔드 포트를 자동으로 감지하여 리버스 프록시를 수행합니다. - **Nginx (Gateway & UserFront)**: 각 인스턴스의 백엔드 포트를 자동으로 감지하여 리버스 프록시를 수행합니다.
- **Ory Kratos**: `allowed_origins`, `allowed_return_urls`, `webhook` 주소 등이 인스턴스 포트에 맞게 자동 치환됩니다. - **Ory Kratos**: `allowed_origins`, `allowed_return_urls`, `webhook` 주소 등이 인스턴스 포트에 맞게 자동 치환됩니다.

View File

@@ -2,6 +2,7 @@
INSTANCE_NAME={{INSTANCE_NAME}} INSTANCE_NAME={{INSTANCE_NAME}}
COMPOSE_PROJECT_NAME=baron-sso-{{INSTANCE_NAME}} COMPOSE_PROJECT_NAME=baron-sso-{{INSTANCE_NAME}}
APP_ENV=production APP_ENV=production
SOURCE_ROOT=../..
# === [2] 포트 Prefix 설정 (예: 23 입력 시 23000, 23432 등 생성) === # === [2] 포트 Prefix 설정 (예: 23 입력 시 23000, 23432 등 생성) ===
P={{PORT_PREFIX}} P={{PORT_PREFIX}}
@@ -27,6 +28,13 @@ USERFRONT_URL=https://{{INSTANCE_NAME}}-sso.${DOMAIN_SUFFIX}
ADMINFRONT_URL=https://{{INSTANCE_NAME}}-admin.${DOMAIN_SUFFIX} ADMINFRONT_URL=https://{{INSTANCE_NAME}}-admin.${DOMAIN_SUFFIX}
DEVFRONT_URL=https://{{INSTANCE_NAME}}-dev.${DOMAIN_SUFFIX} DEVFRONT_URL=https://{{INSTANCE_NAME}}-dev.${DOMAIN_SUFFIX}
ORGFRONT_URL=https://{{INSTANCE_NAME}}-org.${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 # OIDC/Auth URL
VITE_OIDC_AUTHORITY=${USERFRONT_URL}/oidc VITE_OIDC_AUTHORITY=${USERFRONT_URL}/oidc

View File

@@ -6,6 +6,7 @@ export default defineConfig({
envPrefix: ["VITE_", "USERFRONT_"], envPrefix: ["VITE_", "USERFRONT_"],
server: { server: {
host: "127.0.0.1", host: "127.0.0.1",
port: 5174,
// 인스턴스별 도메인을 자동으로 허용 // 인스턴스별 도메인을 자동으로 허용
allowedHosts: ["{{DEVFRONT_DOMAIN}}", "localhost", "127.0.0.1"], allowedHosts: ["{{DEVFRONT_DOMAIN}}", "localhost", "127.0.0.1"],
proxy: { proxy: {
@@ -17,7 +18,7 @@ export default defineConfig({
}, },
preview: { preview: {
host: "127.0.0.1", host: "127.0.0.1",
port: 5173, port: 5174,
allowedHosts: ["{{DEVFRONT_DOMAIN}}", "localhost", "127.0.0.1"], allowedHosts: ["{{DEVFRONT_DOMAIN}}", "localhost", "127.0.0.1"],
proxy: { proxy: {
"/api": { "/api": {

View 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}

View File

@@ -288,7 +288,7 @@ services:
ports: ports:
- "${BACKEND_PORT}:${BACKEND_PORT}" - "${BACKEND_PORT}:${BACKEND_PORT}"
volumes: volumes:
- ../../adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro - ${SOURCE_ROOT:-../..}/adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
networks: [app_net] networks: [app_net]
depends_on: depends_on:
postgres: { condition: service_healthy } postgres: { condition: service_healthy }
@@ -297,7 +297,7 @@ services:
gateway: gateway:
build: build:
context: ../.. context: ${SOURCE_ROOT:-../..}
dockerfile: ./userfront/Dockerfile dockerfile: ./userfront/Dockerfile
target: production target: production
container_name: ${COMPOSE_PROJECT_NAME}_gateway container_name: ${COMPOSE_PROJECT_NAME}_gateway
@@ -305,11 +305,20 @@ services:
- "${USERFRONT_PORT}:80" - "${USERFRONT_PORT}:80"
volumes: volumes:
- ./gateway/nginx.conf:/etc/nginx/nginx.conf:ro - ./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: adminfront:
build: build:
context: ../.. context: ${SOURCE_ROOT:-../..}
dockerfile: ./adminfront/Dockerfile dockerfile: ./adminfront/Dockerfile
args: args:
VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL} VITE_ADMIN_PUBLIC_URL: ${ADMINFRONT_URL}
@@ -323,11 +332,20 @@ services:
- API_PROXY_TARGET=http://backend:${BACKEND_PORT} - API_PROXY_TARGET=http://backend:${BACKEND_PORT}
ports: ports:
- "${ADMINFRONT_PORT}:5173" - "${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: devfront:
build: build:
context: ../.. context: ${SOURCE_ROOT:-../..}
dockerfile: ./devfront/Dockerfile dockerfile: ./devfront/Dockerfile
args: args:
VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL} VITE_DEVFRONT_PUBLIC_URL: ${DEVFRONT_URL}
@@ -339,12 +357,21 @@ services:
- APP_ENV=${APP_ENV:-production} - APP_ENV=${APP_ENV:-production}
- API_PROXY_TARGET=http://backend:${BACKEND_PORT} - API_PROXY_TARGET=http://backend:${BACKEND_PORT}
ports: ports:
- "${DEVFRONT_PORT}:5173" - "${DEVFRONT_PORT}:5174"
networks: [app_net] 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: orgfront:
build: build:
context: ../.. context: ${SOURCE_ROOT:-../..}
dockerfile: ./orgfront/Dockerfile dockerfile: ./orgfront/Dockerfile
args: args:
VITE_ORGFRONT_PUBLIC_URL: ${ORGFRONT_URL} VITE_ORGFRONT_PUBLIC_URL: ${ORGFRONT_URL}
@@ -358,11 +385,23 @@ services:
- USERFRONT_URL=${USERFRONT_URL} - USERFRONT_URL=${USERFRONT_URL}
ports: ports:
- "${ORGFRONT_PORT}:5175" - "${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: networks:
app_net: app_net:
name: ${COMPOSE_PROJECT_NAME}_net name: ${COMPOSE_PROJECT_NAME}_net
traefik_public:
external: true
name: ${TRAEFIK_PUBLIC_NETWORK:-traefik-public}
volumes: volumes:
db_data: db_data:

View File

@@ -27,9 +27,9 @@ FROM deps AS dev
WORKDIR /workspace/devfront WORKDIR /workspace/devfront
ENV NODE_ENV=development 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 FROM deps AS build
@@ -41,11 +41,11 @@ FROM node:24-alpine AS production
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV FRONTEND_DIST_DIR=/app/dist ENV FRONTEND_DIST_DIR=/app/dist
ENV PORT=5173 ENV PORT=5174
COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs COPY scripts/serve_frontend_prod.mjs ./serve_frontend_prod.mjs
COPY --from=build /workspace/devfront/dist ./dist COPY --from=build /workspace/devfront/dist ./dist
EXPOSE 5173 EXPOSE 5174
CMD ["node", "./serve_frontend_prod.mjs"] CMD ["node", "./serve_frontend_prod.mjs"]

View File

@@ -136,8 +136,8 @@ ensure_frontend_dependencies
if [ "$mode" = "production" ]; then if [ "$mode" = "production" ]; then
echo "Running in production mode with Vite preview..." 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 fi
echo "Running in development mode..." 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

View File

@@ -26,6 +26,7 @@ export default defineConfig(
}, },
server: { server: {
host: "127.0.0.1", host: "127.0.0.1",
port: 5174,
allowedHosts, allowedHosts,
proxy: { proxy: {
"/api": { "/api": {
@@ -36,7 +37,7 @@ export default defineConfig(
}, },
preview: { preview: {
host: "127.0.0.1", host: "127.0.0.1",
port: 5173, port: 5174,
allowedHosts, allowedHosts,
proxy: { proxy: {
"/api": { "/api": {

View File

@@ -94,7 +94,7 @@ services:
VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY} VITE_OIDC_AUTHORITY: ${VITE_OIDC_AUTHORITY}
VITE_OIDC_CLIENT_ID: devfront VITE_OIDC_CLIENT_ID: devfront
container_name: baron_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 working_dir: /workspace/devfront
env_file: env_file:
- .env - .env
@@ -105,7 +105,7 @@ services:
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false} - VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
- DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true} - DEV_SERVER_WATCH_POLLING=${DEV_SERVER_WATCH_POLLING:-true}
ports: ports:
- "${DEVFRONT_PORT:-5174}:5173" - "${DEVFRONT_PORT:-5174}:5174"
volumes: volumes:
- ./devfront:/workspace/devfront - ./devfront:/workspace/devfront
- ./common:/common - ./common:/common

View File

@@ -56,7 +56,7 @@ services:
- APP_ENV=stage - APP_ENV=stage
- API_PROXY_TARGET=http://baron_backend:3000 - API_PROXY_TARGET=http://baron_backend:3000
ports: ports:
- "${DEVFRONT_PORT:-5174}:5173" - "${DEVFRONT_PORT:-5174}:5174"
networks: networks:
- baron_net - baron_net

View File

@@ -84,7 +84,7 @@ services:
- APP_ENV=production - APP_ENV=production
- API_PROXY_TARGET=http://baron_backend:${BACKEND_PORT:-3000} - API_PROXY_TARGET=http://baron_backend:${BACKEND_PORT:-3000}
ports: ports:
- "${DEVFRONT_PORT:-5174}:5173" - "${DEVFRONT_PORT:-5174}:5174"
depends_on: depends_on:
backend: backend:
condition: service_healthy condition: service_healthy

View File

@@ -464,11 +464,11 @@ services:
- APP_ENV=${APP_ENV:-stage} - APP_ENV=${APP_ENV:-stage}
- API_PROXY_TARGET=http://baron_backend:3000 - API_PROXY_TARGET=http://baron_backend:3000
ports: ports:
- "${DEVFRONT_PORT:-5174}:5173" - "${DEVFRONT_PORT:-5174}:5174"
networks: networks:
- baron_net - baron_net
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 12 retries: 12

View File

@@ -287,9 +287,10 @@ Upload flow:
2. `dump.sh``reports/backup-report.md`를 생성한다. report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록된다. 2. `dump.sh``reports/backup-report.md`를 생성한다. report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록된다.
3. `upload_cloud.sh`가 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 압축한다. 3. `upload_cloud.sh`가 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 압축한다.
4. Drive API용 access token을 확인한다. 4. Drive API용 access token을 확인한다.
- 우선순위: `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD` - `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`는 항상 최우선이다.
- fallback 1: `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 Drive 앱 access token 갱신 - `WORKS_DRIVE_AUTH_MODE=auto` 기본값은 service account credentials가 완비되어 있으면 `WORKS_DRIVE_OAUTH_*` 서비스 계정 JWT 토큰 발급을 먼저 사용하고, 없으면 `WORKS_DRIVE_OAUTH_REFRESH_TOKEN` 갱신으로 fallback한다.
- fallback 2: `WORKS_DRIVE_OAUTH_*` 서비스 계정 JWT 토큰 발급 - `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를 호출한다. 5. WORKS Drive upload URL 생성 API를 호출한다.
6. 발급받은 upload URL에 multipart `Filedata``.tar.zst` archive를 업로드한다. 6. 발급받은 upload URL에 multipart `Filedata``.tar.zst` archive를 업로드한다.
7. `WORKS_DRIVE_UPLOAD_REPORTS=true`이면 대상 폴더 아래 `WORKS_DRIVE_REPORT_FOLDER_NAME` 하위 폴더를 찾거나 생성한 뒤 `reports/*.md`만 업로드한다. 7. `WORKS_DRIVE_UPLOAD_REPORTS=true`이면 대상 폴더 아래 `WORKS_DRIVE_REPORT_FOLDER_NAME` 하위 폴더를 찾거나 생성한 뒤 `reports/*.md`만 업로드한다.
@@ -312,8 +313,13 @@ Upload flow:
- Drive API는 `file` scope가 필요하다. - Drive API는 `file` scope가 필요하다.
- `WORKS_DRIVE_PARENT_FILE_ID`는 폴더 이름이나 경로가 아니라 WORKS Drive API가 반환하는 폴더 `fileId`여야 한다. - `WORKS_DRIVE_PARENT_FILE_ID`는 폴더 이름이나 경로가 아니라 WORKS Drive API가 반환하는 폴더 `fileId`여야 한다.
- 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 백업 업로드용 `WORKS_DRIVE_OAUTH_*`는 용도가 다른 앱/키로 분리한다. - 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 백업 업로드용 `WORKS_DRIVE_OAUTH_*`는 용도가 다른 앱/키로 분리한다.
- 운영 기본 경로는 Drive용 access token을 명시 주입하거나 `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 갱신하는 방식이다. - 운영 기본 경로는 Drive용 access token을 명시 주입하거나 service account JWT를 사용하는 방식이다.
- 서비스 계정 JWT fallback은 Drive 업로드 앱 정책에서 Drive scope 위임이 허용된 경우에만 성공한다. - 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 업로드를 사용한다. - 파일 크기가 WORKS Drive 단일 파일 제한에 걸릴 수 있으면 `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` 또는 `WORKS_DRIVE_FORCE_SPLIT=true`로 split part 업로드를 사용한다.
- Markdown report 업로드 기본 폴더명은 `reports`이며 `WORKS_DRIVE_REPORT_FOLDER_NAME`으로 바꿀 수 있다. - Markdown report 업로드 기본 폴더명은 `reports`이며 `WORKS_DRIVE_REPORT_FOLDER_NAME`으로 바꿀 수 있다.
- 외부 업로드 성공은 복구 가능성을 보장하지 않는다. 업로드 후에도 `make dump-verify BACKUP=...`와 restore rehearsal을 별도로 수행해야 한다. - 외부 업로드 성공은 복구 가능성을 보장하지 않는다. 업로드 후에도 `make dump-verify BACKUP=...`와 restore rehearsal을 별도로 수행해야 한다.

View 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에 맞추는 것이다.

View 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는 지원하지 않는다.

View 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

View File

@@ -17,6 +17,7 @@ if [[ -f "$repo_root/.env" ]]; then
WORKS_DRIVE_ACCESS_TOKEN WORKS_DRIVE_ACCESS_TOKEN
WORKS_DRIVE_ACCESS_TOKEN_FILE WORKS_DRIVE_ACCESS_TOKEN_FILE
WORKS_DRIVE_ACCESS_TOKEN_CMD WORKS_DRIVE_ACCESS_TOKEN_CMD
WORKS_DRIVE_AUTH_MODE
WORKS_DRIVE_OAUTH_SCOPE WORKS_DRIVE_OAUTH_SCOPE
WORKS_DRIVE_SPLIT_SIZE WORKS_DRIVE_SPLIT_SIZE
WORKS_DRIVE_MAX_SINGLE_FILE_BYTES WORKS_DRIVE_MAX_SINGLE_FILE_BYTES
@@ -43,7 +44,7 @@ if [[ -f "$repo_root/.env" ]]; then
declare -A env_override_values=() declare -A env_override_values=()
env_override_set=() env_override_set=()
for env_key in "${env_override_keys[@]}"; do 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_set+=("$env_key")
env_override_values["$env_key"]="${!env_key}" env_override_values["$env_key"]="${!env_key}"
fi 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}" dry_run="${WORKS_DRIVE_DRY_RUN:-false}"
target="${WORKS_DRIVE_TARGET:-sharedrive}" target="${WORKS_DRIVE_TARGET:-sharedrive}"
auth_mode="${WORKS_DRIVE_AUTH_MODE:-auto}"
api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}" api_base_url="${WORKS_ADMIN_API_BASE_URL:-https://www.worksapis.com}"
curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}" curl_bin="${WORKS_DRIVE_CURL_BIN:-curl}"
archive_dir="${WORKS_DRIVE_ARCHIVE_DIR:-/tmp/baron-sso-backup-upload}" 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_folder_name="${WORKS_DRIVE_REPORT_FOLDER_NAME:-reports}"
report_dir="$backup_path/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 if [[ -f "$backup_path" ]]; then
report_dir="$(dirname "$backup_path")" report_dir="$(dirname "$backup_path")"
fi fi
@@ -330,6 +337,13 @@ request_refresh_access_token() {
jq -er '.access_token' <<<"$response_body" 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() { resolve_access_token() {
if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN:-}" ]]; then if [[ -n "${WORKS_DRIVE_ACCESS_TOKEN:-}" ]]; then
printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN" printf '%s\n' "$WORKS_DRIVE_ACCESS_TOKEN"
@@ -347,12 +361,27 @@ resolve_access_token() {
return return
fi 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
if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then if [[ -n "${WORKS_DRIVE_OAUTH_REFRESH_TOKEN:-}" ]]; then
request_refresh_access_token request_refresh_access_token
return return
fi fi
request_service_account_token request_service_account_token
;;
esac
} }
package_backup_path() { package_backup_path() {

View 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"

View 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"

View 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"

View 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/" assert_dry_run_contains "$target_dry_run" "scripts/backup/"
done 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="$( 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" "docker run"
assert_dry_run_contains "$upload_dry_run" "scripts/backup/upload_cloud.sh" 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" "BACKUP=\"backups/example\""
assert_dry_run_contains "$upload_dry_run" "WORKS_DRIVE_DRY_RUN=\"true\"" 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 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." fail "make restore must fail when BACKUP and CONFIRM_RESTORE are not provided."

View File

@@ -37,6 +37,17 @@ printf '%s\n' "$*" >>"${FAKE_CURL_LOG}"
last_arg="${!#}" last_arg="${!#}"
case "$last_arg" in 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) https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/folder-1/children)
printf '{"files":[]}' printf '{"files":[]}'
;; ;;
@@ -67,12 +78,31 @@ cat >"$fake_bin/zstd" <<'EOF'
cat cat
EOF EOF
chmod +x "$fake_bin/zstd" 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_ACCESS_TOKEN="test-access-token" \
WORKS_DRIVE_TARGET="sharedrive" \ WORKS_DRIVE_TARGET="sharedrive" \
WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \ WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \
WORKS_DRIVE_PARENT_FILE_ID="folder-1" \ WORKS_DRIVE_PARENT_FILE_ID="folder-1" \
WORKS_DRIVE_CURL_BIN="$fake_curl" \ WORKS_DRIVE_CURL_BIN="$fake_curl" \
WORKS_DRIVE_ARCHIVE_DIR="$tmp_dir/archive" \
FAKE_CURL_LOG="$curl_log" \ FAKE_CURL_LOG="$curl_log" \
PATH="$fake_bin:$PATH" \ PATH="$fake_bin:$PATH" \
BACKUP="$backup_dir" \ 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." [[ -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." 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_DRY_RUN=true \
WORKS_DRIVE_TARGET="sharedrive" \ WORKS_DRIVE_TARGET="sharedrive" \
WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \ WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \
WORKS_DRIVE_PARENT_FILE_ID="folder-1" \ WORKS_DRIVE_PARENT_FILE_ID="folder-1" \
WORKS_DRIVE_ARCHIVE_DIR="$tmp_dir/archive" \
PATH="$fake_bin:$PATH" \ PATH="$fake_bin:$PATH" \
BACKUP="$backup_dir" \ BACKUP="$backup_dir" \
"$repo_root/scripts/backup/upload_cloud.sh" >"$tmp_dir/dry-run.out" "$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." 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"

View 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"

View 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

View 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

View 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

View 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"

View 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"