forked from baron/baron-sso
백업/복구로직 변경, 깜빡임 버그 해결
This commit is contained in:
28
.env.sample
28
.env.sample
@@ -36,6 +36,34 @@ CORS_ALLOWED_ORIGINS=http://localhost:5000 # 쿠키 인증 사용 시 정확한
|
|||||||
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
|
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
|
||||||
WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token
|
WORKS_ADMIN_OAUTH_TOKEN_URL=https://auth.worksmobile.com/oauth2/v2.0/token
|
||||||
|
|
||||||
|
# --- NAVER WORKS Drive backup upload ---
|
||||||
|
# Drive API 업로드에는 `file` scope가 필요합니다.
|
||||||
|
# 운영에서는 Drive 권한이 위임된 사용자/OAuth access token을 우선 사용하세요.
|
||||||
|
# 서비스 계정 JWT 방식은 WORKS 앱 정책에서 Drive API scope 위임이 허용된 경우에만 사용할 수 있습니다.
|
||||||
|
WORKS_DRIVE_TARGET=sharedrive
|
||||||
|
WORKS_DRIVE_SHARED_DRIVE_ID=
|
||||||
|
WORKS_DRIVE_PARENT_FILE_ID=
|
||||||
|
WORKS_DRIVE_USER_ID=me
|
||||||
|
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=file
|
||||||
|
WORKS_DRIVE_OAUTH_CLIENT_ID=
|
||||||
|
WORKS_DRIVE_OAUTH_CLIENT_SECRET=
|
||||||
|
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT=
|
||||||
|
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE=./config/worksmobile-driveapp-private-key.pem
|
||||||
|
WORKS_DRIVE_OAUTH_REFRESH_TOKEN=
|
||||||
|
WORKS_DRIVE_OAUTH_REDIRECT_URI=
|
||||||
|
WORKS_DRIVE_SPLIT_SIZE=9000M
|
||||||
|
WORKS_DRIVE_MAX_SINGLE_FILE_BYTES=0
|
||||||
|
WORKS_DRIVE_FORCE_SPLIT=false
|
||||||
|
WORKS_DRIVE_OVERWRITE=false
|
||||||
|
WORKS_DRIVE_DRY_RUN=false
|
||||||
|
WORKS_DRIVE_UPLOAD_REPORTS=true
|
||||||
|
WORKS_DRIVE_REPORT_FOLDER_NAME=reports
|
||||||
|
|
||||||
|
|
||||||
# Audit System Configuration
|
# Audit System Configuration
|
||||||
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
AUDIT_WORKER_COUNT=5 # 비동기 감사 로그 처리를 위한 고루틴 워커 수
|
||||||
|
|||||||
67
Makefile
67
Makefile
@@ -29,7 +29,22 @@ ifneq (,$(wildcard ./.env))
|
|||||||
COMPOSE_DROP_ENV_ARGS += --env-file .env
|
COMPOSE_DROP_ENV_ARGS += --env-file .env
|
||||||
endif
|
endif
|
||||||
|
|
||||||
.PHONY: 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 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
|
DUMP_SERVICES ?= all
|
||||||
|
RESTORE_SERVICES ?= all
|
||||||
|
DUMP_MODE ?= maintenance
|
||||||
|
BACKUP_USE_DOCKER ?= true
|
||||||
|
BACKUP_TOOLS_IMAGE ?= baron-sso-backup-tools:local
|
||||||
|
BACKUP_TOOLS_DOCKERFILE ?= docker/backup-tools/Dockerfile
|
||||||
|
BACKUP_DOCKER_ENV_ARGS :=
|
||||||
|
ifneq (,$(wildcard ./.env))
|
||||||
|
BACKUP_DOCKER_ENV_ARGS += --env-file .env
|
||||||
|
endif
|
||||||
|
ifneq (,$(wildcard ./$(AUTH_CONFIG_ENV)))
|
||||||
|
BACKUP_DOCKER_ENV_ARGS += --env-file $(AUTH_CONFIG_ENV)
|
||||||
|
endif
|
||||||
|
BACKUP_DOCKER_RUN = docker run --rm $(BACKUP_DOCKER_ENV_ARGS) -e BACKUP_REPO_ROOT=/workspace -v /var/run/docker.sock:/var/run/docker.sock -v "$(CURDIR)":/workspace -v /tmp:/tmp -w /workspace $(BACKUP_TOOLS_IMAGE)
|
||||||
|
|
||||||
|
.PHONY: 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 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
|
||||||
|
|
||||||
# --- 인증 설정 빌드/검증 ---
|
# --- 인증 설정 빌드/검증 ---
|
||||||
build-auth-config:
|
build-auth-config:
|
||||||
@@ -188,6 +203,56 @@ logs-ory:
|
|||||||
logs-app:
|
logs-app:
|
||||||
docker compose -f $(COMPOSE_APP) logs -f
|
docker compose -f $(COMPOSE_APP) logs -f
|
||||||
|
|
||||||
|
# --- 백업/복구 ---
|
||||||
|
backup-tools-build:
|
||||||
|
docker build -f $(BACKUP_TOOLS_DOCKERFILE) -t $(BACKUP_TOOLS_IMAGE) .
|
||||||
|
|
||||||
|
ifeq ($(BACKUP_USE_DOCKER),true)
|
||||||
|
dump: backup-tools-build
|
||||||
|
$(BACKUP_DOCKER_RUN) bash -lc 'DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh'
|
||||||
|
|
||||||
|
restore: backup-tools-build
|
||||||
|
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh'
|
||||||
|
|
||||||
|
dump-verify: backup-tools-build
|
||||||
|
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh'
|
||||||
|
|
||||||
|
restore-verify: backup-tools-build
|
||||||
|
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh'
|
||||||
|
|
||||||
|
dump-list: backup-tools-build
|
||||||
|
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh'
|
||||||
|
|
||||||
|
restore-plan: backup-tools-build
|
||||||
|
$(BACKUP_DOCKER_RUN) bash -lc 'BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh'
|
||||||
|
|
||||||
|
upload-cloud: backup-tools-build
|
||||||
|
$(BACKUP_DOCKER_RUN) bash -lc 'WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh'
|
||||||
|
else
|
||||||
|
dump:
|
||||||
|
DUMP_SERVICES="$(DUMP_SERVICES)" DUMP_MODE="$(DUMP_MODE)" BACKUP="$(BACKUP)" BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump.sh
|
||||||
|
|
||||||
|
restore:
|
||||||
|
BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" ALLOW_NON_EMPTY_RESTORE="$(ALLOW_NON_EMPTY_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore.sh
|
||||||
|
|
||||||
|
dump-verify:
|
||||||
|
BACKUP="$(BACKUP)" scripts/backup/verify-dump.sh
|
||||||
|
|
||||||
|
restore-verify:
|
||||||
|
BACKUP="$(BACKUP)" scripts/backup/verify-restore.sh
|
||||||
|
|
||||||
|
dump-list:
|
||||||
|
BACKUP_ROOT="$(BACKUP_ROOT)" scripts/backup/dump-list.sh
|
||||||
|
|
||||||
|
restore-plan:
|
||||||
|
BACKUP="$(BACKUP)" DUMP_FILE="$(DUMP_FILE)" RESTORE_SERVICES="$(RESTORE_SERVICES)" CONFIRM_RESTORE="$(CONFIRM_RESTORE)" RESTORE_REPORT="$(RESTORE_REPORT)" scripts/backup/restore-plan.sh
|
||||||
|
|
||||||
|
upload-cloud:
|
||||||
|
WORKS_DRIVE_DRY_RUN="$(WORKS_DRIVE_DRY_RUN)" BACKUP="$(BACKUP)" scripts/backup/upload_cloud.sh
|
||||||
|
endif
|
||||||
|
|
||||||
|
dump-upload-cloud: dump upload-cloud
|
||||||
|
|
||||||
# --- 로컬 통합 코드 체크 ---
|
# --- 로컬 통합 코드 체크 ---
|
||||||
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
|
||||||
|
|||||||
149
README.md
149
README.md
@@ -527,6 +527,155 @@ docker compose --env-file .env --env-file config/.generated/auth-config.env -f d
|
|||||||
- **Hydra Public**: http://localhost:4444
|
- **Hydra Public**: http://localhost:4444
|
||||||
- **Kratos UI (UserFront)**: http://localhost:5000
|
- **Kratos UI (UserFront)**: http://localhost:5000
|
||||||
|
|
||||||
|
### 전체 백업/복구
|
||||||
|
|
||||||
|
전체 백업/복구는 CSV export/import가 아니라 Baron SSO와 Ory Stack 저장소를 같은 시점의 재해 복구 단위로 보존하는 절차입니다. 사용자 UUID, Kratos identity ID, Hydra/Keto 원장, WORKS 연동 mapping이 어긋나면 안 되므로 운영 복구는 DB dump와 설정 snapshot을 함께 다룹니다.
|
||||||
|
|
||||||
|
#### 백업 실행
|
||||||
|
```bash
|
||||||
|
# 전체 백업
|
||||||
|
make dump
|
||||||
|
|
||||||
|
# 출력 위치를 직접 지정
|
||||||
|
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||||
|
|
||||||
|
# 일부 서비스만 백업
|
||||||
|
make dump DUMP_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config
|
||||||
|
make dump DUMP_SERVICES=ory-postgres,ory-clickhouse
|
||||||
|
|
||||||
|
# 생성된 백업 검증
|
||||||
|
make dump-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||||
|
|
||||||
|
# WORKS Drive로 외부 분산 저장
|
||||||
|
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||||
|
|
||||||
|
# 지정 경로로 dump 후 바로 WORKS Drive 업로드
|
||||||
|
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||||
|
|
||||||
|
# 로컬 백업 목록
|
||||||
|
make dump-list
|
||||||
|
```
|
||||||
|
|
||||||
|
기본값은 `DUMP_SERVICES=all`, `DUMP_MODE=maintenance`입니다. `DUMP_SERVICES`는 다음 값을 콤마로 조합할 수 있습니다.
|
||||||
|
|
||||||
|
| 값 | 대상 |
|
||||||
|
| --- | --- |
|
||||||
|
| `postgres` | Baron Postgres (`baron_postgres`, `${DB_NAME:-baron_sso}`) |
|
||||||
|
| `ory-postgres` | Ory Postgres의 `${KRATOS_DB:-ory_kratos}`, `${HYDRA_DB:-ory_hydra}`, `${KETO_DB:-ory_keto}` |
|
||||||
|
| `clickhouse` | Baron ClickHouse (`baron_clickhouse`) |
|
||||||
|
| `ory-clickhouse` | Ory ClickHouse (`ory_clickhouse`) |
|
||||||
|
| `config` | `.env` redacted copy, generated Ory config, gateway, 주요 compose 파일 |
|
||||||
|
|
||||||
|
백업 산출물은 기본적으로 `backups/baron-sso-backup-YYYYMMDD-HHMMSSZ/` 아래에 생성됩니다.
|
||||||
|
|
||||||
|
```text
|
||||||
|
manifest.json
|
||||||
|
checksums.sha256
|
||||||
|
postgres/
|
||||||
|
clickhouse/
|
||||||
|
config/
|
||||||
|
reports/
|
||||||
|
```
|
||||||
|
|
||||||
|
#### WORKS Drive 외부 업로드
|
||||||
|
|
||||||
|
`make dump`, `make restore`, `make upload-cloud`는 기본적으로 `docker/backup-tools/Dockerfile`에서 빌드한 `baron-sso-backup-tools:local` 컨테이너 안에서 실행됩니다. 호스트에는 Docker와 Docker socket 접근 권한만 필요하고, `zstd`, `jq`, `curl`, `openssl`, `postgresql-client` 같은 백업/복구 도구는 backup-tools image에 포함됩니다.
|
||||||
|
|
||||||
|
`make upload-cloud`는 기존 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 묶은 뒤 WORKS Drive에 업로드합니다. 압축 포맷은 `.tar.zst`로 고정되어 있고, 압축/해제는 backup-tools 컨테이너 내부의 `zstd`로 수행합니다.
|
||||||
|
|
||||||
|
백업이 완료되면 `reports/backup-report.md`도 생성됩니다. 이 report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록됩니다. `make upload-cloud`는 `reports/*.md`만 WORKS Drive 대상 폴더 아래의 `reports` 하위 폴더로 업로드하며, 업로드 파일명은 `backup-report-YYYYMMDD-HHMMSSZ.md`처럼 업로드 시각을 붙입니다. `reports/cloud-upload.json`은 로컬 업로드 실행 기록으로만 남기고 Drive에는 업로드하지 않습니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 권장: 백업 경로를 명시해서 dump와 upload를 분리
|
||||||
|
make dump BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||||
|
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||||
|
|
||||||
|
# 또는 같은 BACKUP 경로로 연속 실행
|
||||||
|
make dump-upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||||
|
|
||||||
|
# 실제 업로드 전 endpoint와 target만 확인
|
||||||
|
make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ WORKS_DRIVE_DRY_RUN=true
|
||||||
|
|
||||||
|
# 예외적으로 호스트 도구로 직접 실행
|
||||||
|
make restore BACKUP_USE_DOCKER=false BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ CONFIRM_RESTORE=baron-sso
|
||||||
|
```
|
||||||
|
|
||||||
|
주요 변수:
|
||||||
|
|
||||||
|
| 변수 | 설명 |
|
||||||
|
| --- | --- |
|
||||||
|
| `WORKS_DRIVE_TARGET` | `sharedrive`, `mydrive`, `group`, `sharedfolder` 중 하나. 기본값은 `sharedrive`입니다. |
|
||||||
|
| `WORKS_DRIVE_SHARED_DRIVE_ID` | `WORKS_DRIVE_TARGET=sharedrive`일 때 공용 드라이브 ID입니다. |
|
||||||
|
| `WORKS_DRIVE_PARENT_FILE_ID` | 업로드할 대상 폴더의 WORKS Drive `fileId`입니다. 폴더 이름이나 경로가 아니며, 비우면 대상 drive/folder root에 업로드합니다. |
|
||||||
|
| `WORKS_DRIVE_USER_ID` | `mydrive` 또는 `sharedfolder` 대상 사용자 ID입니다. 기본값은 `me`입니다. |
|
||||||
|
| `WORKS_DRIVE_GROUP_ID` | `WORKS_DRIVE_TARGET=group`일 때 조직/그룹 ID입니다. |
|
||||||
|
| `WORKS_DRIVE_SHARED_FOLDER_ID` | `WORKS_DRIVE_TARGET=sharedfolder`일 때 공유받은 폴더 ID입니다. |
|
||||||
|
| `WORKS_DRIVE_ACCESS_TOKEN` | Drive API 호출용 Bearer token입니다. Drive API는 `file` scope가 필요합니다. |
|
||||||
|
| `WORKS_DRIVE_ACCESS_TOKEN_FILE` | access token을 파일에서 읽을 때 사용합니다. |
|
||||||
|
| `WORKS_DRIVE_ACCESS_TOKEN_CMD` | access token을 명령 출력으로 주입할 때 사용합니다. |
|
||||||
|
| `WORKS_DRIVE_OAUTH_SCOPE` | Drive 업로드 앱 OAuth token에 사용할 scope입니다. 기본값은 `file`입니다. |
|
||||||
|
| `WORKS_DRIVE_OAUTH_CLIENT_ID` | Drive 업로드 앱의 OAuth client ID입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_CLIENT_ID`와 분리합니다. |
|
||||||
|
| `WORKS_DRIVE_OAUTH_CLIENT_SECRET` | Drive 업로드 앱의 OAuth client secret입니다. |
|
||||||
|
| `WORKS_DRIVE_OAUTH_REFRESH_TOKEN` | Drive 업로드 앱의 refresh token입니다. 명시 access token이 없으면 이 값으로 access token을 갱신합니다. |
|
||||||
|
| `WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT` | Drive 업로드 앱의 service account입니다. JWT `sub`에 들어갑니다. |
|
||||||
|
| `WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE` | Drive 업로드 앱 private key 파일입니다. 예: `./config/worksmobile-driveapp-private-key.pem` |
|
||||||
|
| `WORKS_DRIVE_SPLIT_SIZE` | 분할 업로드 시 part 크기입니다. 기본값은 `9000M`입니다. |
|
||||||
|
| `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` | 이 값보다 archive가 크면 split part로 나눕니다. 기본값 `0`은 자동 분할 비활성입니다. |
|
||||||
|
| `WORKS_DRIVE_FORCE_SPLIT` | `true`이면 크기와 무관하게 split part로 업로드합니다. |
|
||||||
|
| `WORKS_DRIVE_OVERWRITE` | WORKS Drive upload URL 생성 요청의 overwrite 플래그입니다. 기본값은 `false`입니다. |
|
||||||
|
| `WORKS_DRIVE_UPLOAD_REPORTS` | `true`이면 `reports/*.md`를 Drive의 report 폴더로 함께 업로드합니다. 기본값은 `true`입니다. |
|
||||||
|
| `WORKS_DRIVE_REPORT_FOLDER_NAME` | Markdown report를 업로드할 하위 폴더 이름입니다. 기본값은 `reports`입니다. |
|
||||||
|
|
||||||
|
Drive API는 업로드 URL 생성 후 해당 URL에 multipart `Filedata`로 실제 파일을 전송하는 2단계 방식입니다. 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 Drive 업로드용 `WORKS_DRIVE_OAUTH_*`는 서로 다른 앱/키로 관리합니다. token 우선순위는 `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`, `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`, 서비스 계정 JWT fallback 순서입니다. 운영에서는 Drive API 권한과 `file` scope 위임 정책을 먼저 확인해야 합니다.
|
||||||
|
|
||||||
|
#### 복구 계획과 복구 실행
|
||||||
|
```bash
|
||||||
|
# 복구 전 계획 확인
|
||||||
|
make restore-plan BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
|
||||||
|
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
|
||||||
|
CONFIRM_RESTORE=baron-sso
|
||||||
|
|
||||||
|
# 복구 실행
|
||||||
|
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
|
||||||
|
RESTORE_SERVICES=postgres,ory-postgres,clickhouse,ory-clickhouse,config \
|
||||||
|
CONFIRM_RESTORE=baron-sso
|
||||||
|
|
||||||
|
# .tar.zst archive를 직접 복구 입력으로 사용
|
||||||
|
make restore DUMP_FILE=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ.tar.zst \
|
||||||
|
RESTORE_SERVICES=all \
|
||||||
|
CONFIRM_RESTORE=baron-sso
|
||||||
|
|
||||||
|
# report 경로를 명시
|
||||||
|
make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ \
|
||||||
|
CONFIRM_RESTORE=baron-sso \
|
||||||
|
RESTORE_REPORT=reports/restore/baron-sso-restore-report.json
|
||||||
|
|
||||||
|
# 복구 후 기본 검증
|
||||||
|
make restore-verify BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ
|
||||||
|
```
|
||||||
|
|
||||||
|
복구는 반드시 빈 volume 또는 restore 전용 stack에서 수행하는 것을 기본 정책으로 합니다. `make restore`는 `BACKUP` 또는 `DUMP_FILE` 중 하나와 `CONFIRM_RESTORE=baron-sso`가 없으면 실패하고, 기본적으로 non-empty Postgres 대상에는 복구하지 않습니다. 승인된 restore rehearsal에서만 `ALLOW_NON_EMPTY_RESTORE=true`를 사용하세요. `DUMP_FILE=.tar.zst` 해제도 backup-tools 컨테이너에서 수행하므로 호스트 `zstd` 설치에 의존하지 않습니다.
|
||||||
|
|
||||||
|
`make restore`는 복구 report를 JSON과 Markdown으로 남깁니다. `BACKUP` 디렉터리 입력의 기본 JSON report는 `<BACKUP>/reports/restore-report.json`이고, `DUMP_FILE` archive 입력의 기본 JSON report는 `reports/restore/<archive-name>-restore-report.json`입니다. 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성됩니다. `RESTORE_REPORT`로 직접 지정할 수 있습니다. report에는 입력 archive, 복구 서비스, checksum 검증 상태, 복구 후 대상 row count 비교 결과가 기록됩니다.
|
||||||
|
|
||||||
|
`config` 복구는 운영 파일을 직접 덮어쓰지 않고 `config-restored/`에 풀어 수동 검토하도록 합니다. migration은 자동 실행하지 않으며, Ory Stack과 backend 기동 후 super admin login, 대표 OIDC login, WORKS comparison dry-run을 통과하기 전까지 WORKS relay를 자동 재개하지 않습니다.
|
||||||
|
|
||||||
|
#### 백업/복구 범위
|
||||||
|
|
||||||
|
필수 백업 대상:
|
||||||
|
- Baron Postgres: users, tenants, user_login_ids, user_groups, RP metadata, WORKS mapping/outbox 등
|
||||||
|
- Ory Postgres: Kratos identity/credentials/session, Hydra client/consent/token state, Keto relation tuple
|
||||||
|
- Baron ClickHouse: 감사 로그와 RP usage event
|
||||||
|
- Ory ClickHouse: Oathkeeper/Ory 계열 접근 로그
|
||||||
|
- 설정 snapshot: `.env` redacted copy, generated Ory config, gateway, compose 파일
|
||||||
|
|
||||||
|
기본 제외 대상:
|
||||||
|
- Redis: pending login, short code, cache 등 휘발성 데이터이므로 복구 후 재수렴 대상으로 봅니다.
|
||||||
|
- 프론트 빌드 산출물: 소스와 이미지 태그로 재생성합니다.
|
||||||
|
- coverage, reports, test-results 같은 로컬 개발 산출물
|
||||||
|
|
||||||
|
상세 설계와 운영 정책은 `docs/backup-restore-design.md`를 기준으로 유지합니다.
|
||||||
|
|
||||||
### MCP 서버 (Hydra/Kratos/Keto)
|
### MCP 서버 (Hydra/Kratos/Keto)
|
||||||
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
|
MCP 서버는 기존 Hydra/Kratos에 연결하며 별도 Ory 스택이나 포트를 추가로 띄우지 않습니다.
|
||||||
프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요.
|
프로덕션에서는 실행하지 않도록 `mcp` 프로파일을 로컬에서만 켜세요.
|
||||||
|
|||||||
56
adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
56
adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import AuthGuard from "./AuthGuard";
|
||||||
|
|
||||||
|
const authState = {
|
||||||
|
activeNavigator: undefined,
|
||||||
|
error: undefined as Error | undefined,
|
||||||
|
isAuthenticated: false,
|
||||||
|
isLoading: false,
|
||||||
|
removeUser: vi.fn(async () => undefined),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-oidc-context", () => ({
|
||||||
|
useAuth: () => authState,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function renderAuthGuard(initialEntry = "/users") {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[initialEntry]}>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AuthGuard />}>
|
||||||
|
<Route path="users" element={<div>Users outlet</div>} />
|
||||||
|
</Route>
|
||||||
|
<Route path="/login" element={<div>Login outlet</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AuthGuard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(
|
||||||
|
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
|
||||||
|
)._IS_TEST_MODE = false;
|
||||||
|
authState.activeNavigator = undefined;
|
||||||
|
authState.error = undefined;
|
||||||
|
authState.isAuthenticated = false;
|
||||||
|
authState.isLoading = false;
|
||||||
|
authState.removeUser.mockClear();
|
||||||
|
window.localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears stale auth state and returns to login when OIDC reports an error", async () => {
|
||||||
|
window.localStorage.setItem("admin_session", "stale-token");
|
||||||
|
authState.error = new Error("stale session");
|
||||||
|
|
||||||
|
renderAuthGuard();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(authState.removeUser).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
await screen.findByText("Login outlet");
|
||||||
|
expect(window.localStorage.getItem("admin_session")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,31 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { Navigate, Outlet, useLocation } from "react-router-dom";
|
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
|
import { clearStoredAdminAuthSession } from "../../lib/auth";
|
||||||
|
|
||||||
export default function AuthGuard() {
|
export default function AuthGuard() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const handledAuthErrorRef = useRef(false);
|
||||||
const isTest =
|
const isTest =
|
||||||
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||||
._IS_TEST_MODE === true;
|
._IS_TEST_MODE === true;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!auth.error || handledAuthErrorRef.current || isTest) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
handledAuthErrorRef.current = true;
|
||||||
|
clearStoredAdminAuthSession();
|
||||||
|
void Promise.resolve(
|
||||||
|
auth.removeUser ? auth.removeUser() : undefined,
|
||||||
|
).finally(() => {
|
||||||
|
navigate("/login", { replace: true });
|
||||||
|
});
|
||||||
|
}, [auth, auth.error, isTest, navigate]);
|
||||||
|
|
||||||
if (isTest) {
|
if (isTest) {
|
||||||
return <Outlet />;
|
return <Outlet />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,7 +49,11 @@ import {
|
|||||||
type UserCreateResponse,
|
type UserCreateResponse,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
|
import {
|
||||||
|
canManageTenantScopedUsers,
|
||||||
|
isSuperAdminRole,
|
||||||
|
normalizeAdminRole,
|
||||||
|
} from "../../lib/roles";
|
||||||
import {
|
import {
|
||||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||||
filterNonHanmacFamilyTenants,
|
filterNonHanmacFamilyTenants,
|
||||||
@@ -154,6 +158,7 @@ function UserCreatePage() {
|
|||||||
queryFn: fetchMe,
|
queryFn: fetchMe,
|
||||||
});
|
});
|
||||||
const profileRole = normalizeAdminRole(profile?.role);
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
|
const canManageUsers = canManageTenantScopedUsers(profile);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -204,8 +209,12 @@ function UserCreatePage() {
|
|||||||
|
|
||||||
// Lock company for non-super_admin
|
// Lock company for non-super_admin
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (profileRole !== "super_admin" && profile?.tenantSlug) {
|
if (profileRole !== "super_admin") {
|
||||||
setValue("tenantSlug", profile.tenantSlug);
|
const delegatedTenantSlug =
|
||||||
|
profile?.tenantSlug || profile?.manageableTenants?.[0]?.slug;
|
||||||
|
if (delegatedTenantSlug) {
|
||||||
|
setValue("tenantSlug", delegatedTenantSlug);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [profile, profileRole, setValue]);
|
}, [profile, profileRole, setValue]);
|
||||||
|
|
||||||
@@ -524,8 +533,7 @@ function UserCreatePage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Access Control: Only super_admin can create users
|
if (profile && !canManageUsers) {
|
||||||
if (profile && profileRole !== "super_admin") {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||||
<ShieldAlert size={48} className="text-destructive" />
|
<ShieldAlert size={48} className="text-destructive" />
|
||||||
|
|||||||
@@ -75,7 +75,10 @@ import {
|
|||||||
updateUser,
|
updateUser,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { normalizeAdminRole } from "../../lib/roles";
|
import {
|
||||||
|
canManageUserInTenantScope,
|
||||||
|
normalizeAdminRole,
|
||||||
|
} from "../../lib/roles";
|
||||||
import { generateSecurePassword } from "../../lib/utils";
|
import { generateSecurePassword } from "../../lib/utils";
|
||||||
import {
|
import {
|
||||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||||
@@ -472,6 +475,7 @@ function UserDetailPage() {
|
|||||||
const profileRole = normalizeAdminRole(profile?.role);
|
const profileRole = normalizeAdminRole(profile?.role);
|
||||||
const isAdmin = profileRole === "super_admin";
|
const isAdmin = profileRole === "super_admin";
|
||||||
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
const isSelf = Boolean(profile?.id && user?.id && profile.id === user.id);
|
||||||
|
const canManageCurrentUser = canManageUserInTenantScope({ profile, user });
|
||||||
const watchedStatus = watch("status");
|
const watchedStatus = watch("status");
|
||||||
|
|
||||||
const [newSubEmail, setNewSubEmail] = React.useState("");
|
const [newSubEmail, setNewSubEmail] = React.useState("");
|
||||||
@@ -999,8 +1003,7 @@ function UserDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Access Control: Only super_admin or self can view details
|
if (!isAdmin && !isSelf && !canManageCurrentUser) {
|
||||||
if (!isAdmin && !isSelf) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
<div className="flex h-[50vh] flex-col items-center justify-center space-y-4">
|
||||||
<ShieldAlert size={48} className="text-destructive" />
|
<ShieldAlert size={48} className="text-destructive" />
|
||||||
|
|||||||
@@ -189,4 +189,19 @@ describe("UserListPage search rendering", () => {
|
|||||||
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
|
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
|
||||||
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
|
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps rendered form fields identifiable for browser autofill diagnostics", async () => {
|
||||||
|
const { container } = renderUserListPage();
|
||||||
|
|
||||||
|
await screen.findByText("User 0");
|
||||||
|
const anonymousFields = Array.from(
|
||||||
|
container.querySelectorAll("input, select, textarea"),
|
||||||
|
).filter(
|
||||||
|
(field) =>
|
||||||
|
!field.getAttribute("id")?.trim() &&
|
||||||
|
!field.getAttribute("name")?.trim(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(anonymousFields).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -207,6 +207,8 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
|||||||
<div className="relative w-48">
|
<div className="relative w-48">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
|
id="user-list-search"
|
||||||
|
name="user-list-search"
|
||||||
placeholder={t(
|
placeholder={t(
|
||||||
"ui.admin.users.list.search_placeholder",
|
"ui.admin.users.list.search_placeholder",
|
||||||
"이름 또는 이메일 검색...",
|
"이름 또는 이메일 검색...",
|
||||||
@@ -223,6 +225,8 @@ const UserListSearchControls = React.memo(function UserListSearchControls({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<select
|
<select
|
||||||
|
id="user-list-tenant-filter"
|
||||||
|
name="user-list-tenant-filter"
|
||||||
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||||
value={selectedCompany}
|
value={selectedCompany}
|
||||||
onChange={(event) => onCompanyChange(event.target.value)}
|
onChange={(event) => onCompanyChange(event.target.value)}
|
||||||
@@ -727,6 +731,7 @@ function UserListPage() {
|
|||||||
className="flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-muted/50"
|
className="flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-muted/50"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
|
name={`user-list-column-${field.key}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
||||||
checked={visibleColumns[field.key] !== false}
|
checked={visibleColumns[field.key] !== false}
|
||||||
@@ -802,6 +807,7 @@ function UserListPage() {
|
|||||||
<TableHead className={`${userTableHeadClassName} w-12`}>
|
<TableHead className={`${userTableHeadClassName} w-12`}>
|
||||||
<div className="flex h-full items-center justify-center">
|
<div className="flex h-full items-center justify-center">
|
||||||
<input
|
<input
|
||||||
|
name="user-list-select-all"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
||||||
checked={
|
checked={
|
||||||
@@ -980,6 +986,7 @@ function UserListPage() {
|
|||||||
>
|
>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<input
|
<input
|
||||||
|
name={`user-list-select-${user.id}`}
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
checked={selectedUserIds.includes(user.id)}
|
checked={selectedUserIds.includes(user.id)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||||
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
|
import { shouldSuppressDevelopmentSessionRedirect } from "../../../common/core/session";
|
||||||
import { userManager } from "./auth";
|
import { clearAdminAuthSession, userManager } from "./auth";
|
||||||
|
|
||||||
let isRedirectingToLogin = false;
|
let isRedirectingToLogin = false;
|
||||||
|
|
||||||
@@ -50,12 +50,7 @@ apiClient.interceptors.response.use(
|
|||||||
"[apiClient] 401 Unauthorized detected. Clearing session state.",
|
"[apiClient] 401 Unauthorized detected. Clearing session state.",
|
||||||
);
|
);
|
||||||
|
|
||||||
// 로컬 스토리지의 세션 키 제거
|
await clearAdminAuthSession();
|
||||||
window.localStorage.removeItem("admin_session");
|
|
||||||
|
|
||||||
// oidc-client의 유저 상태도 제거하여 isAuthenticated를 false로 만듭니다.
|
|
||||||
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
|
|
||||||
await userManager.removeUser();
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
shouldStartLoginRedirect({
|
shouldStartLoginRedirect({
|
||||||
|
|||||||
@@ -21,3 +21,31 @@ export const oidcConfig: AuthProviderProps = buildCommonOidcRuntimeConfig({
|
|||||||
export const userManager = new UserManager(
|
export const userManager = new UserManager(
|
||||||
buildCommonUserManagerSettings(oidcConfig),
|
buildCommonUserManagerSettings(oidcConfig),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export function clearStoredAdminAuthSession(
|
||||||
|
storage: Storage = window.localStorage,
|
||||||
|
) {
|
||||||
|
const keysToRemove: string[] = [];
|
||||||
|
|
||||||
|
for (let index = 0; index < storage.length; index += 1) {
|
||||||
|
const key = storage.key(index);
|
||||||
|
if (
|
||||||
|
key &&
|
||||||
|
(key === "admin_session" ||
|
||||||
|
key.startsWith("oidc.user:") ||
|
||||||
|
key.startsWith("oidc.state") ||
|
||||||
|
key.startsWith("oidc.signin"))
|
||||||
|
) {
|
||||||
|
keysToRemove.push(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of keysToRemove) {
|
||||||
|
storage.removeItem(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearAdminAuthSession() {
|
||||||
|
clearStoredAdminAuthSession();
|
||||||
|
await userManager.removeUser();
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
canManageTenantScopedUsers,
|
||||||
|
canManageUserInTenantScope,
|
||||||
isSuperAdminRole,
|
isSuperAdminRole,
|
||||||
normalizeAdminRole,
|
normalizeAdminRole,
|
||||||
ROLE_SUPER_ADMIN,
|
ROLE_SUPER_ADMIN,
|
||||||
@@ -32,4 +34,43 @@ describe("admin role helpers", () => {
|
|||||||
expect(isSuperAdminRole("admin")).toBe(false);
|
expect(isSuperAdminRole("admin")).toBe(false);
|
||||||
expect(isSuperAdminRole(undefined)).toBe(false);
|
expect(isSuperAdminRole(undefined)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("allows delegated tenant admins with manageable tenants to manage scoped users", () => {
|
||||||
|
const profile = {
|
||||||
|
id: "admin-user",
|
||||||
|
role: "user",
|
||||||
|
manageableTenants: [{ id: "tenant-1", slug: "tenant-a" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canManageTenantScopedUsers(profile)).toBe(true);
|
||||||
|
expect(
|
||||||
|
canManageUserInTenantScope({
|
||||||
|
profile,
|
||||||
|
user: { id: "user-1", tenantSlug: "tenant-a" },
|
||||||
|
}),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
canManageUserInTenantScope({
|
||||||
|
profile,
|
||||||
|
user: { id: "user-2", tenantSlug: "tenant-b" },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat ordinary tenant membership as delegated user management", () => {
|
||||||
|
const profile = {
|
||||||
|
id: "member-user",
|
||||||
|
role: "user",
|
||||||
|
tenantSlug: "tenant-a",
|
||||||
|
manageableTenants: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(canManageTenantScopedUsers(profile)).toBe(false);
|
||||||
|
expect(
|
||||||
|
canManageUserInTenantScope({
|
||||||
|
profile,
|
||||||
|
user: { id: "user-1", tenantSlug: "tenant-a" },
|
||||||
|
}),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,21 @@ export const ROLE_USER = "user";
|
|||||||
|
|
||||||
export type AdminRole = typeof ROLE_SUPER_ADMIN | typeof ROLE_USER;
|
export type AdminRole = typeof ROLE_SUPER_ADMIN | typeof ROLE_USER;
|
||||||
|
|
||||||
|
export type TenantAccessSubject = {
|
||||||
|
id?: string | null;
|
||||||
|
role?: string | null;
|
||||||
|
tenantId?: string | null;
|
||||||
|
tenantSlug?: string | null;
|
||||||
|
tenant?: {
|
||||||
|
id?: string | null;
|
||||||
|
slug?: string | null;
|
||||||
|
} | null;
|
||||||
|
manageableTenants?: Array<{
|
||||||
|
id?: string | null;
|
||||||
|
slug?: string | null;
|
||||||
|
}> | null;
|
||||||
|
};
|
||||||
|
|
||||||
export function normalizeAdminRole(role?: string | null): AdminRole {
|
export function normalizeAdminRole(role?: string | null): AdminRole {
|
||||||
const normalized = role?.trim().toLowerCase() ?? "";
|
const normalized = role?.trim().toLowerCase() ?? "";
|
||||||
|
|
||||||
@@ -30,3 +45,60 @@ export function normalizeAdminRole(role?: string | null): AdminRole {
|
|||||||
export function isSuperAdminRole(role?: string | null) {
|
export function isSuperAdminRole(role?: string | null) {
|
||||||
return normalizeAdminRole(role) === ROLE_SUPER_ADMIN;
|
return normalizeAdminRole(role) === ROLE_SUPER_ADMIN;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeTenantAccessKey(value?: string | null) {
|
||||||
|
const normalized = value?.trim().toLowerCase();
|
||||||
|
return normalized ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getManageableTenantAccessKeys(
|
||||||
|
profile?: TenantAccessSubject | null,
|
||||||
|
) {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
for (const tenant of profile?.manageableTenants ?? []) {
|
||||||
|
const id = normalizeTenantAccessKey(tenant.id);
|
||||||
|
const slug = normalizeTenantAccessKey(tenant.slug);
|
||||||
|
if (id) keys.add(id);
|
||||||
|
if (slug) keys.add(slug);
|
||||||
|
}
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageTenantScopedUsers(
|
||||||
|
profile?: TenantAccessSubject | null,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
isSuperAdminRole(profile?.role) ||
|
||||||
|
getManageableTenantAccessKeys(profile).size > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canManageUserInTenantScope({
|
||||||
|
profile,
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
profile?: TenantAccessSubject | null;
|
||||||
|
user?: TenantAccessSubject | null;
|
||||||
|
}) {
|
||||||
|
if (isSuperAdminRole(profile?.role)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile?.id && user?.id && profile.id === user.id) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const manageableKeys = getManageableTenantAccessKeys(profile);
|
||||||
|
if (manageableKeys.size === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userTenantKeys = [
|
||||||
|
normalizeTenantAccessKey(user?.tenantId),
|
||||||
|
normalizeTenantAccessKey(user?.tenantSlug),
|
||||||
|
normalizeTenantAccessKey(user?.tenant?.id),
|
||||||
|
normalizeTenantAccessKey(user?.tenant?.slug),
|
||||||
|
];
|
||||||
|
|
||||||
|
return userTenantKeys.some((key) => key !== null && manageableKeys.has(key));
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
|||||||
page.on("console", (msg) => console.log(`[PAGE] ${msg.text()}`));
|
page.on("console", (msg) => console.log(`[PAGE] ${msg.text()}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
const setupAuth = async (page, role: string) => {
|
const setupAuth = async (
|
||||||
|
page,
|
||||||
|
role: string,
|
||||||
|
profileOverrides: Record<string, unknown> = {},
|
||||||
|
) => {
|
||||||
// 1. Inject initial state and mock tokens
|
// 1. Inject initial state and mock tokens
|
||||||
await page.addInitScript(
|
await page.addInitScript(
|
||||||
({ role }) => {
|
({ role }) => {
|
||||||
@@ -76,6 +80,7 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
|||||||
email: "test@example.com",
|
email: "test@example.com",
|
||||||
role: role,
|
role: role,
|
||||||
manageableTenants: [],
|
manageableTenants: [],
|
||||||
|
...profileOverrides,
|
||||||
},
|
},
|
||||||
headers: { "Access-Control-Allow-Origin": "*" },
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
});
|
});
|
||||||
@@ -95,6 +100,28 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
|||||||
},
|
},
|
||||||
headers: { "Access-Control-Allow-Origin": "*" },
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
});
|
});
|
||||||
|
} else if (url.match(/\/admin\/users\/u1$/)) {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "u1",
|
||||||
|
name: "사용자 1",
|
||||||
|
email: "u1@example.com",
|
||||||
|
role: "user",
|
||||||
|
status: "active",
|
||||||
|
tenantId: "t1",
|
||||||
|
tenantSlug: "t1",
|
||||||
|
tenant: {
|
||||||
|
id: "t1",
|
||||||
|
name: "테넌트 1",
|
||||||
|
slug: "t1",
|
||||||
|
status: "active",
|
||||||
|
type: "COMPANY",
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
} else if (url.includes("/rp-history")) {
|
} else if (url.includes("/rp-history")) {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: [],
|
json: [],
|
||||||
@@ -218,4 +245,52 @@ test.describe("보안 및 접근 제어: 시스템 관리자 vs 일반 사용자
|
|||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.describe("테넌트 관리자 권한", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await setupAuth(page, "tenant_admin", {
|
||||||
|
tenantId: "t1",
|
||||||
|
tenantSlug: "t1",
|
||||||
|
manageableTenants: [
|
||||||
|
{
|
||||||
|
id: "t1",
|
||||||
|
name: "테넌트 1",
|
||||||
|
slug: "t1",
|
||||||
|
status: "active",
|
||||||
|
type: "COMPANY",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page.locator("aside")).toBeVisible({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("사용자 관리 목록에 접근 가능해야 함", async ({ page }) => {
|
||||||
|
await page.goto("/users");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByTestId("page-title").filter({ hasText: /사용자 관리/i }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByText("사용자 1")).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("사용자 생성 화면에 접근 가능해야 함", async ({ page }) => {
|
||||||
|
await page.goto("/users/new");
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole("heading", { name: "사용자 추가" }),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("관리 대상 테넌트 사용자 상세에 접근 가능해야 함", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/users/u1");
|
||||||
|
|
||||||
|
await expect(page.getByText("사용자 1")).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByText(/이 작업을 수행할 권한이 없습니다/i),
|
||||||
|
).not.toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -236,21 +236,12 @@ services:
|
|||||||
|
|
||||||
# 기본 RP (Admin Front 등) 자동 등록 컨테이너
|
# 기본 RP (Admin Front 등) 자동 등록 컨테이너
|
||||||
init-rp:
|
init-rp:
|
||||||
image: alpine:latest
|
image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
entrypoint: ["/bin/sh", "-ec"]
|
||||||
command:
|
command:
|
||||||
- /bin/sh
|
|
||||||
- -ec
|
|
||||||
- |
|
- |
|
||||||
apk add --no-cache curl tar
|
|
||||||
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
|
|
||||||
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
|
|
||||||
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
|
|
||||||
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
|
|
||||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
|
||||||
rm /tmp/hydra.tar.gz
|
|
||||||
|
|
||||||
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" adminfront >/dev/null 2>&1 || true
|
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" adminfront >/dev/null 2>&1 || true
|
||||||
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" devfront >/dev/null 2>&1 || true
|
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" devfront >/dev/null 2>&1 || true
|
||||||
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" orgfront >/dev/null 2>&1 || true
|
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" orgfront >/dev/null 2>&1 || true
|
||||||
@@ -270,21 +261,21 @@ services:
|
|||||||
--endpoint "$${HYDRA_ADMIN_URL}" \
|
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||||
--id devfront \
|
--id devfront \
|
||||||
--name "DevFront" \
|
--name "DevFront" \
|
||||||
--grant-type authorization_code,refresh_token \
|
--grant-type authorization_code,refresh_token \
|
||||||
--response-type code \
|
--response-type code \
|
||||||
--scope openid,offline_access,profile,email \
|
--scope openid,offline_access,profile,email \
|
||||||
--token-endpoint-auth-method none \
|
--token-endpoint-auth-method none \
|
||||||
--redirect-uri ${DEVFRONT_CALLBACK_URLS}
|
--redirect-uri ${DEVFRONT_CALLBACK_URLS}
|
||||||
|
|
||||||
hydra create oauth2-client \
|
hydra create oauth2-client \
|
||||||
--endpoint "$${HYDRA_ADMIN_URL}" \
|
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||||
--id orgfront \
|
--id orgfront \
|
||||||
--name "OrgFront" \
|
--name "OrgFront" \
|
||||||
--grant-type authorization_code,refresh_token \
|
--grant-type authorization_code,refresh_token \
|
||||||
--response-type code \
|
--response-type code \
|
||||||
--scope openid,offline_access,profile,email \
|
--scope openid,offline_access,profile,email \
|
||||||
--token-endpoint-auth-method none \
|
--token-endpoint-auth-method none \
|
||||||
--redirect-uri ${ORGFRONT_CALLBACK_URLS}
|
--redirect-uri ${ORGFRONT_CALLBACK_URLS}
|
||||||
|
|
||||||
hydra create oauth2-client \
|
hydra create oauth2-client \
|
||||||
--endpoint "$${HYDRA_ADMIN_URL}" \
|
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||||
|
|||||||
82
config-restored/compose/compose.infra.yaml
Normal file
82
config-restored/compose/compose.infra.yaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
container_name: baron_postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${DB_USER:-baron}
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASSWORD:-password}
|
||||||
|
POSTGRES_DB: ${DB_NAME:-baron_sso}
|
||||||
|
ports:
|
||||||
|
- "${DB_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
- ./docker/init-metadata:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${DB_USER:-baron} -d ${DB_NAME:-baron_sso}",
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: always
|
||||||
|
|
||||||
|
clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:latest
|
||||||
|
container_name: baron_clickhouse
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- clickhouse_data:/var/lib/clickhouse
|
||||||
|
environment:
|
||||||
|
CLICKHOUSE_USER: ${CLICKHOUSE_USER:-baron}
|
||||||
|
CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-password}
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: baron_redis
|
||||||
|
restart: always
|
||||||
|
command: redis-server --port 6389
|
||||||
|
ports:
|
||||||
|
- "6389:6389"
|
||||||
|
volumes:
|
||||||
|
- redis_data:/data
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
gateway:
|
||||||
|
build:
|
||||||
|
context: ./gateway
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: baron_gateway
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "${USERFRONT_PORT:-5000}:5000"
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
- public_net
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
clickhouse_data:
|
||||||
|
redis_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
baron_net:
|
||||||
|
name: baron_net
|
||||||
|
external: true
|
||||||
|
driver: bridge
|
||||||
|
public_net:
|
||||||
|
name: public_net
|
||||||
|
external: true
|
||||||
|
|
||||||
319
config-restored/compose/compose.ory.yaml
Normal file
319
config-restored/compose/compose.ory.yaml
Normal file
@@ -0,0 +1,319 @@
|
|||||||
|
services:
|
||||||
|
postgres_ory:
|
||||||
|
image: postgres:${ORY_POSTGRES_TAG:-17-alpine}
|
||||||
|
container_name: ory_postgres
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=${ORY_POSTGRES_USER:-ory}
|
||||||
|
- POSTGRES_PASSWORD=${ORY_POSTGRES_PASSWORD:-secret}
|
||||||
|
- POSTGRES_DB=${ORY_POSTGRES_DB:-ory}
|
||||||
|
volumes:
|
||||||
|
- ./docker/ory/init-db:/docker-entrypoint-initdb.d
|
||||||
|
- ory_postgres_data:/var/lib/postgresql/data
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD-SHELL",
|
||||||
|
"pg_isready -U ${ORY_POSTGRES_USER:-ory} -d ${KRATOS_DB:-ory_kratos}",
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# --- Kratos ---
|
||||||
|
kratos-migrate:
|
||||||
|
image: oryd/kratos:${KRATOS_VERSION:-v26.2.0}
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${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}
|
||||||
|
- 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
|
||||||
|
command: migrate sql up -e -c /etc/config/kratos/kratos.yml --yes
|
||||||
|
depends_on:
|
||||||
|
postgres_ory:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
kratos:
|
||||||
|
image: oryd/kratos:${KRATOS_VERSION:-v26.2.0}
|
||||||
|
container_name: ory_kratos
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB:-ory_kratos}?sslmode=disable&max_conns=20
|
||||||
|
- COOKIE_SECRET=${COOKIE_SECRET:-localcookie123}
|
||||||
|
- KRATOS_SERVE_PUBLIC_BASE_URL=${KRATOS_BROWSER_URL}
|
||||||
|
- KRATOS_SERVE_ADMIN_BASE_URL=${KRATOS_ADMIN_URL}
|
||||||
|
- 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
|
||||||
|
command: serve -c /etc/config/kratos/kratos.yml --dev --watch-courier
|
||||||
|
depends_on:
|
||||||
|
kratos-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
- kratosnet
|
||||||
|
|
||||||
|
# --- Hydra ---
|
||||||
|
hydra-migrate:
|
||||||
|
image: oryd/hydra:${HYDRA_VERSION:-v26.2.0}
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB:-ory_hydra}?sslmode=disable&max_conns=20
|
||||||
|
command: migrate sql up -e --yes
|
||||||
|
depends_on:
|
||||||
|
postgres_ory:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
hydra:
|
||||||
|
image: oryd/hydra:${HYDRA_VERSION:-v26.2.0}
|
||||||
|
container_name: ory_hydra
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${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
|
||||||
|
command: serve -c /etc/config/hydra/hydra.yml all --dev
|
||||||
|
depends_on:
|
||||||
|
hydra-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
- hydranet
|
||||||
|
|
||||||
|
# --- Keto ---
|
||||||
|
keto-migrate:
|
||||||
|
image: oryd/keto:${KETO_VERSION:-v26.2.0}
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
|
||||||
|
volumes:
|
||||||
|
- ./config/.generated/ory/keto:/etc/config/keto
|
||||||
|
command: ["migrate", "up", "-c", "/etc/config/keto/keto.yml", "--yes"]
|
||||||
|
depends_on:
|
||||||
|
postgres_ory:
|
||||||
|
condition: service_healthy
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
keto:
|
||||||
|
image: oryd/keto:${KETO_VERSION:-v26.2.0}
|
||||||
|
container_name: ory_keto
|
||||||
|
environment:
|
||||||
|
- DSN=postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB:-ory_keto}?sslmode=disable&max_conns=20
|
||||||
|
volumes:
|
||||||
|
- ./config/.generated/ory/keto:/etc/config/keto
|
||||||
|
command: serve -c /etc/config/keto/keto.yml
|
||||||
|
depends_on:
|
||||||
|
keto-migrate:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
# --- Oathkeeper ---
|
||||||
|
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:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
oathkeeper:
|
||||||
|
image: oryd/oathkeeper:${OATHKEEPER_VERSION:-v26.2.0}
|
||||||
|
container_name: ory_oathkeeper
|
||||||
|
user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}"
|
||||||
|
ports:
|
||||||
|
- "4457:4455" # Proxy
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- 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
|
||||||
|
- oathkeeper_logs:/var/log/oathkeeper
|
||||||
|
entrypoint: ["/etc/config/oathkeeper/entrypoint.sh"]
|
||||||
|
depends_on:
|
||||||
|
oathkeeper_logs_init:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
- public_net
|
||||||
|
|
||||||
|
ory_clickhouse:
|
||||||
|
image: clickhouse/clickhouse-server:latest
|
||||||
|
container_name: ory_clickhouse
|
||||||
|
environment:
|
||||||
|
- CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory}
|
||||||
|
- CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass}
|
||||||
|
volumes:
|
||||||
|
- ory_clickhouse_data:/var/lib/clickhouse
|
||||||
|
- ./docker/ory/clickhouse:/docker-entrypoint-initdb.d
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
ory_vector:
|
||||||
|
image: timberio/vector:0.36.0-alpine
|
||||||
|
container_name: ory_vector
|
||||||
|
environment:
|
||||||
|
- ORY_CLICKHOUSE_USER=${ORY_CLICKHOUSE_USER:-ory}
|
||||||
|
- ORY_CLICKHOUSE_PASSWORD=${ORY_CLICKHOUSE_PASSWORD:-orypass}
|
||||||
|
volumes:
|
||||||
|
- ./docker/ory/vector:/etc/vector
|
||||||
|
- oathkeeper_logs:/var/log/oathkeeper
|
||||||
|
command: ["-c", "/etc/vector/vector.toml"]
|
||||||
|
depends_on:
|
||||||
|
- oathkeeper
|
||||||
|
- ory_clickhouse
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
# --- 초기화 & 헬스체크 ---
|
||||||
|
ory_stack_check:
|
||||||
|
image: alpine:latest
|
||||||
|
container_name: ory_stack_check
|
||||||
|
command: >
|
||||||
|
/bin/sh -c "
|
||||||
|
apk add --no-cache curl;
|
||||||
|
echo 'Wait for 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 ory_$$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 fully operational!';"
|
||||||
|
depends_on:
|
||||||
|
- kratos
|
||||||
|
- hydra
|
||||||
|
- keto
|
||||||
|
networks:
|
||||||
|
- ory-net
|
||||||
|
|
||||||
|
# 기본 RP (Admin Front 등) 자동 등록 컨테이너
|
||||||
|
init-rp:
|
||||||
|
image: alpine:latest
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
command:
|
||||||
|
- /bin/sh
|
||||||
|
- -ec
|
||||||
|
- |
|
||||||
|
apk add --no-cache curl tar
|
||||||
|
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
|
||||||
|
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
|
||||||
|
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
|
||||||
|
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
|
||||||
|
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
||||||
|
rm /tmp/hydra.tar.gz
|
||||||
|
|
||||||
|
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" adminfront >/dev/null 2>&1 || true
|
||||||
|
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" devfront >/dev/null 2>&1 || true
|
||||||
|
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" orgfront >/dev/null 2>&1 || true
|
||||||
|
hydra delete oauth2-client --endpoint "$${HYDRA_ADMIN_URL}" "$${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}" >/dev/null 2>&1 || true
|
||||||
|
|
||||||
|
hydra create oauth2-client \
|
||||||
|
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||||
|
--id 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}
|
||||||
|
|
||||||
|
hydra create oauth2-client \
|
||||||
|
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||||
|
--id 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}
|
||||||
|
|
||||||
|
hydra create oauth2-client \
|
||||||
|
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||||
|
--id 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}
|
||||||
|
|
||||||
|
hydra create oauth2-client \
|
||||||
|
--endpoint "$${HYDRA_ADMIN_URL}" \
|
||||||
|
--id "$${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:
|
||||||
|
- hydranet
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ory_postgres_data:
|
||||||
|
ory_clickhouse_data:
|
||||||
|
oathkeeper_logs:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
ory-net:
|
||||||
|
external: true
|
||||||
|
name: ory-net
|
||||||
|
hydranet:
|
||||||
|
external: true
|
||||||
|
name: hydranet
|
||||||
|
kratosnet:
|
||||||
|
external: true
|
||||||
|
name: kratosnet
|
||||||
|
public_net:
|
||||||
|
external: true
|
||||||
|
name: public_net
|
||||||
184
config-restored/compose/docker-compose.yaml
Normal file
184
config-restored/compose/docker-compose.yaml
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: baron_backend
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- GO_ENV=${APP_ENV:-development}
|
||||||
|
- BACKEND_LOG_LEVEL=${BACKEND_LOG_LEVEL:-info}
|
||||||
|
- CLIENT_LOG_DEBUG=${CLIENT_LOG_DEBUG:-false}
|
||||||
|
- WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL}
|
||||||
|
- WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL}
|
||||||
|
- COOKIE_SECRET=${COOKIE_SECRET}
|
||||||
|
- JWT_SECRET=${JWT_SECRET}
|
||||||
|
- NAVER_CLOUD_ACCESS_KEY=${NAVER_CLOUD_ACCESS_KEY}
|
||||||
|
- NAVER_CLOUD_SECRET_KEY=${NAVER_CLOUD_SECRET_KEY}
|
||||||
|
- NAVER_CLOUD_SERVICE_ID=${NAVER_CLOUD_SERVICE_ID}
|
||||||
|
- NAVER_SENDER_PHONE_NUMBER=${NAVER_SENDER_PHONE_NUMBER}
|
||||||
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
|
- REDIS_ADDR=${REDIS_ADDR}
|
||||||
|
- IDP_PROVIDER=${IDP_PROVIDER:-ory}
|
||||||
|
- KRATOS_ADMIN_URL=${KRATOS_ADMIN_URL:-http://kratos:4434}
|
||||||
|
- HYDRA_ADMIN_URL=${HYDRA_ADMIN_URL:-http://hydra:4445}
|
||||||
|
- HYDRA_PUBLIC_URL=${HYDRA_PUBLIC_URL:-http://hydra:4444}
|
||||||
|
- KETO_READ_URL=${KETO_READ_URL:-http://keto:4466}
|
||||||
|
- KETO_WRITE_URL=${KETO_WRITE_URL:-http://keto:4467}
|
||||||
|
- DB_HOST=postgres
|
||||||
|
- CLICKHOUSE_HOST=clickhouse
|
||||||
|
- CLICKHOUSE_PORT=${CLICKHOUSE_PORT_NATIVE:-9000}
|
||||||
|
- CLICKHOUSE_USER=${CLICKHOUSE_USER:-baron}
|
||||||
|
- CLICKHOUSE_PASSWORD=${CLICKHOUSE_PASSWORD:-password}
|
||||||
|
- SEED_TENANT_CSV_PATH=/app/seed-tenant.csv
|
||||||
|
depends_on:
|
||||||
|
- infra_check
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
- ory-net
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
- ./config:/app/config:ro
|
||||||
|
- ./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro
|
||||||
|
command: ["go", "run", "./cmd/server"]
|
||||||
|
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
adminfront:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./adminfront/Dockerfile
|
||||||
|
container_name: baron_adminfront
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
|
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
|
||||||
|
ports:
|
||||||
|
- "${ADMINFRONT_PORT:-5173}:5173"
|
||||||
|
volumes:
|
||||||
|
- ./adminfront:/workspace/adminfront
|
||||||
|
- ./common:/common
|
||||||
|
- ./common:/workspace/common
|
||||||
|
- /workspace/common/node_modules
|
||||||
|
- ./locales:/locales
|
||||||
|
- ./locales:/workspace/locales
|
||||||
|
- /workspace/adminfront/node_modules
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
devfront:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./devfront/Dockerfile
|
||||||
|
container_name: baron_devfront
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
|
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
|
||||||
|
ports:
|
||||||
|
- "${DEVFRONT_PORT:-5174}:5173"
|
||||||
|
volumes:
|
||||||
|
- ./devfront:/workspace/devfront
|
||||||
|
- ./common:/common
|
||||||
|
- ./common:/workspace/common
|
||||||
|
- /workspace/common/node_modules
|
||||||
|
- ./locales:/locales
|
||||||
|
- ./locales:/workspace/locales
|
||||||
|
- /workspace/devfront/node_modules
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
orgfront:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./orgfront/Dockerfile
|
||||||
|
container_name: baron_orgfront
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
|
- VITE_CLIENT_LOG_DEBUG=${VITE_CLIENT_LOG_DEBUG:-false}
|
||||||
|
ports:
|
||||||
|
- "${ORGFRONT_PORT:-5175}:5175"
|
||||||
|
volumes:
|
||||||
|
- ./orgfront:/workspace/orgfront
|
||||||
|
- ./common:/common
|
||||||
|
- ./common:/workspace/common
|
||||||
|
- /workspace/common/node_modules
|
||||||
|
- ./locales:/locales
|
||||||
|
- ./locales:/workspace/locales
|
||||||
|
- /workspace/orgfront/node_modules
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
|
||||||
|
userfront:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: userfront/Dockerfile
|
||||||
|
target: ${USERFRONT_BUILD_TARGET:-dev}
|
||||||
|
container_name: baron_userfront
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
- BACKEND_URL=${BACKEND_URL:-}
|
||||||
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
|
- APP_ENV=${APP_ENV}
|
||||||
|
- CLIENT_LOG_DEBUG=${CLIENT_LOG_DEBUG:-false}
|
||||||
|
- USERFRONT_INTERNAL_PORT=5000
|
||||||
|
- USERFRONT_FLUTTER_RUN_FLAGS=${USERFRONT_FLUTTER_RUN_FLAGS:-}
|
||||||
|
volumes:
|
||||||
|
- ./userfront/lib:/workspace/userfront/lib
|
||||||
|
- ./userfront/assets:/workspace/userfront/assets
|
||||||
|
- ./userfront/web:/workspace/userfront/web
|
||||||
|
- ./userfront/scripts:/workspace/userfront/scripts:ro
|
||||||
|
- ./scripts:/workspace/scripts:ro
|
||||||
|
- ./locales:/workspace/locales:ro
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
- ory-net
|
||||||
|
depends_on:
|
||||||
|
backend:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:5000/"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
# Dummy service to wait for infra network if needed,
|
||||||
|
# but essentially we assume infra is running.
|
||||||
|
# In a real unified stack, we might include infra here or use external links.
|
||||||
|
# Here we attach to the same network.
|
||||||
|
infra_check:
|
||||||
|
image: alpine
|
||||||
|
command: ["echo", "Infrastructure assumed running"]
|
||||||
|
networks:
|
||||||
|
- baron_net
|
||||||
|
|
||||||
|
networks:
|
||||||
|
baron_net:
|
||||||
|
external: true
|
||||||
|
name: baron_net
|
||||||
|
ory-net:
|
||||||
|
external: true
|
||||||
|
name: ory-net
|
||||||
|
public_net:
|
||||||
|
external: true
|
||||||
|
name: public_net
|
||||||
119
config-restored/env.redacted
Normal file
119
config-restored/env.redacted
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
APP_ENV=dev
|
||||||
|
BACKEND_LOG_LEVEL=debug
|
||||||
|
CLIENT_LOG_DEBUG=true
|
||||||
|
TZ=Asia/Seoul
|
||||||
|
IDP_PROVIDER=ory
|
||||||
|
|
||||||
|
# DB & Clickhouse
|
||||||
|
DB_PORT=5432
|
||||||
|
CLICKHOUSE_PORT_HTTP=8123
|
||||||
|
CLICKHOUSE_PORT_NATIVE=9000
|
||||||
|
CLICKHOUSE_HOST=clickhouse
|
||||||
|
CLICKHOUSE_USER=baron
|
||||||
|
CLICKHOUSE_PASSWORD=REDACTED
|
||||||
|
|
||||||
|
|
||||||
|
BACKEND_PORT=3000
|
||||||
|
ADMINFRONT_PORT=5173
|
||||||
|
DEVFRONT_PORT=5174
|
||||||
|
USERFRONT_PORT=5000
|
||||||
|
|
||||||
|
OATHKEEPER_API_URL=http://oathkeeper:4456
|
||||||
|
|
||||||
|
DB_USER=baron
|
||||||
|
DB_PASSWORD=REDACTED
|
||||||
|
DB_NAME=baron_sso
|
||||||
|
COOKIE_SECRET=REDACTED
|
||||||
|
JWT_SECRET=REDACTED
|
||||||
|
REDIS_ADDR=redis:6389
|
||||||
|
CORS_ALLOWED_ORIGINS='*'
|
||||||
|
AUDIT_WORKER_COUNT=5
|
||||||
|
AUDIT_QUEUE_SIZE=2000
|
||||||
|
PROFILE_CACHE_TTL=
|
||||||
|
NAVER_CLOUD_ACCESS_KEY=REDACTED
|
||||||
|
NAVER_CLOUD_SECRET_KEY=REDACTED
|
||||||
|
NAVER_CLOUD_SERVICE_ID=ncp:sms:kr:364022321777:baroncs
|
||||||
|
NAVER_SENDER_PHONE_NUMBER=0262857755
|
||||||
|
AWS_REGION=ap-northeast-2
|
||||||
|
AWS_ACCESS_KEY_ID=REDACTED
|
||||||
|
AWS_SECRET_ACCESS_KEY=REDACTED
|
||||||
|
AWS_SES_SENDER=support@baroncs.co.kr
|
||||||
|
# ADMIN_EMAIL=admin@hmac.kr
|
||||||
|
ADMIN_EMAIL=su-@samaneng.com
|
||||||
|
ADMIN_PASSWORD=REDACTED
|
||||||
|
USERFRONT_URL=http://localhost:5000
|
||||||
|
# USERFRONT_URL=http://172.16.9.189:5000
|
||||||
|
ADMINFRONT_URL=http://localhost:5173
|
||||||
|
DEVFRONT_URL=http://localhost:5174
|
||||||
|
VITE_ORGCHART_URL=http://localhost:5175
|
||||||
|
ORGFRONT_URL=http://localhost:5175
|
||||||
|
BACKEND_PUBLIC_URL=${USERFRONT_URL}
|
||||||
|
BACKEND_URL=${USERFRONT_URL}
|
||||||
|
# OATHKEEPER_PUBLIC_URL=http://172.16.9.189:5000
|
||||||
|
OATHKEEPER_PUBLIC_URL=http://localhost:5000
|
||||||
|
|
||||||
|
ORY_POSTGRES_TAG=17-trixie
|
||||||
|
ORY_POSTGRES_USER=ory
|
||||||
|
ORY_POSTGRES_PASSWORD=REDACTED
|
||||||
|
ORY_POSTGRES_DB=ory
|
||||||
|
KRATOS_DB=ory_kratos
|
||||||
|
HYDRA_DB=ory_hydra
|
||||||
|
KETO_DB=ory_keto
|
||||||
|
KRATOS_VERSION=v26.2.0-distroless
|
||||||
|
HYDRA_VERSION=v26.2.0-distroless
|
||||||
|
KETO_VERSION=v26.2.0-distroless
|
||||||
|
ORY_SDK_URL=http://kratos:4433
|
||||||
|
KRATOS_PUBLIC_URL=http://kratos:4433
|
||||||
|
KRATOS_ADMIN_URL=http://kratos:4434
|
||||||
|
KRATOS_BROWSER_URL=http://localhost:5000/auth
|
||||||
|
KRATOS_UI_URL=http://localhost:5000
|
||||||
|
HYDRA_ADMIN_URL=http://hydra:4445
|
||||||
|
HYDRA_PUBLIC_URL=http://localhost:5000/oidc
|
||||||
|
JWKS_URL=http://oathkeeper:4456/.well-known/jwks.json
|
||||||
|
OATHKEEPER_VERSION=v26.2.0
|
||||||
|
OATHKEEPER_UID=1001
|
||||||
|
OATHKEEPER_GID=1001
|
||||||
|
OATHKEEPER_HEALTH_URL=http://oathkeeper:4456/health/ready
|
||||||
|
OATHKEEPER_HEALTH_INTERVAL_SECONDS=10
|
||||||
|
OATHKEEPER_HEALTH_TIMEOUT_SECONDS=2
|
||||||
|
OATHKEEPER_HEALTH_ENABLED=true
|
||||||
|
CSRF_COOKIE_NAME=REDACTED
|
||||||
|
CSRF_COOKIE_SECRET=REDACTED
|
||||||
|
|
||||||
|
# Frontend OIDC configs for Staging
|
||||||
|
VITE_OIDC_AUTHORITY=http://localhost:5000/oidc
|
||||||
|
ADMINFRONT_CALLBACK_URLS=http://localhost:5173/auth/callback
|
||||||
|
DEVFRONT_CALLBACK_URLS=http://localhost:5174/auth/callback
|
||||||
|
ORGFRONT_CALLBACK_URLS=http://localhost:5175/auth/callback
|
||||||
|
# OATHKEEPER_INTROSPECT_CLIENT_ID=
|
||||||
|
# OATHKEEPER_INTROSPECT_CLIENT_SECRET=
|
||||||
|
|
||||||
|
#Worksmobile
|
||||||
|
SAMAN_DOMAIN_ID=300285955
|
||||||
|
HANMAC_DOMAIN_ID=300286336
|
||||||
|
GPDTDC_DOMAIN_ID=300286337
|
||||||
|
BARONGROUP_DOMAIN_ID=300286645
|
||||||
|
HALLA_DOMAIN_ID=300293726
|
||||||
|
SAMAN_TENANT_ID=300285955
|
||||||
|
SAMAN_SCIM_LONGLIVE_TOKEN=REDACTED
|
||||||
|
WORKS_ADMIN_OAUTH_CLIENT_ID=JrD1iPz73ugTFV5XL_zO
|
||||||
|
WORKS_ADMIN_OAUTH_CLIENT_SECRET=REDACTED
|
||||||
|
WORKS_ADMIN_OAUTH_CLIENT_SERVICE_ACCOUNT=e3n9j.serviceaccount@samaneng.com
|
||||||
|
WORKS_ADMIN_OAUTH_CLIENT_PRIVATE_KEY_FILE=REDACTED
|
||||||
|
WORKS_DEFAULT_DOMAIN_SAMAN=samaneng.com
|
||||||
|
WORKS_DEFAULT_DOMAIN_HANMAC=hanmaceng.co.kr
|
||||||
|
WORKS_DEFAULT_DOMAIN_GPDTDC=baroncs.co.kr
|
||||||
|
WORKS_DEFAULT_DOMAIN_BARONGROUP=brsw.kr
|
||||||
|
WORKS_DEFAULT_DOMAIN_HALLA=hallasanup.com
|
||||||
|
WORKS_ADMIN_API_BASE_URL=https://www.worksapis.com
|
||||||
|
WORKS_ADMIN_OAUTH_TOKEN_URL=REDACTED
|
||||||
|
|
||||||
|
WORKS_DRIVE_OAUTH_CLIENT_ID=9JapAnmjI9M_1SqDp4Uj
|
||||||
|
WORKS_DRIVE_OAUTH_CLIENT_SECRET=REDACTED
|
||||||
|
WORKS_DRIVE_OAUTH_CLIENT_SERVICE_ACCOUNT=h4bq6.serviceaccount@samaneng.com
|
||||||
|
WORKS_DRIVE_OAUTH_CLIENT_PRIVATE_KEY_FILE=REDACTED
|
||||||
|
WORKS_DRIVE_APP_PASSWORD=REDACTED
|
||||||
|
WORKS_DRIVE_OAUTH_REDIRECT_URI=https://drive.hmac.kr/works/callback
|
||||||
|
WORKS_DRIVE_OAUTH_REFRESH_TOKEN=REDACTED
|
||||||
|
WORKS_DRIVE_SHARED_DRIVE_ID=@2001000000540386
|
||||||
|
WORKS_DRIVE_PARENT_FILE_ID=QDIwMDEwMDAwMDA1NDAzODZ8MzQ3MjYxMzYwMzE0NjY2NDk2OXxEfDA
|
||||||
BIN
config-restored/gateway.tar.zst
Normal file
BIN
config-restored/gateway.tar.zst
Normal file
Binary file not shown.
BIN
config-restored/generated-ory.tar.zst
Normal file
BIN
config-restored/generated-ory.tar.zst
Normal file
Binary file not shown.
@@ -218,20 +218,11 @@ services:
|
|||||||
networks: [app_net]
|
networks: [app_net]
|
||||||
|
|
||||||
init-rp:
|
init-rp:
|
||||||
image: alpine:latest
|
image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0}
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
entrypoint: ["/bin/sh", "-ec"]
|
||||||
command:
|
command:
|
||||||
- /bin/sh
|
|
||||||
- -ec
|
|
||||||
- |
|
- |
|
||||||
apk add --no-cache curl tar
|
|
||||||
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
|
|
||||||
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
|
|
||||||
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
|
|
||||||
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
|
|
||||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
|
||||||
rm /tmp/hydra.tar.gz
|
|
||||||
|
|
||||||
upsert_client() {
|
upsert_client() {
|
||||||
ID=$$1
|
ID=$$1
|
||||||
shift
|
shift
|
||||||
|
|||||||
24
docker/backup-tools/Dockerfile
Normal file
24
docker/backup-tools/Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM debian:trixie-slim
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
bash \
|
||||||
|
ca-certificates \
|
||||||
|
coreutils \
|
||||||
|
curl \
|
||||||
|
docker-cli \
|
||||||
|
findutils \
|
||||||
|
git \
|
||||||
|
grep \
|
||||||
|
jq \
|
||||||
|
openssl \
|
||||||
|
perl \
|
||||||
|
postgresql-client \
|
||||||
|
sed \
|
||||||
|
tar \
|
||||||
|
util-linux \
|
||||||
|
zstd \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /workspace
|
||||||
|
CMD ["/bin/bash"]
|
||||||
@@ -182,22 +182,13 @@ services:
|
|||||||
- ory-net
|
- ory-net
|
||||||
|
|
||||||
init-rp:
|
init-rp:
|
||||||
image: alpine:latest
|
image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0}
|
||||||
container_name: init-rp
|
container_name: init-rp
|
||||||
env_file:
|
env_file:
|
||||||
- ../.env
|
- ../.env
|
||||||
|
entrypoint: ["/bin/sh", "-ec"]
|
||||||
command:
|
command:
|
||||||
- /bin/sh
|
|
||||||
- -ec
|
|
||||||
- |
|
- |
|
||||||
apk add --no-cache curl tar
|
|
||||||
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
|
|
||||||
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
|
|
||||||
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
|
|
||||||
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
|
|
||||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
|
||||||
rm /tmp/hydra.tar.gz
|
|
||||||
|
|
||||||
echo "Creating/Updating OAuth2 Clients..."
|
echo "Creating/Updating OAuth2 Clients..."
|
||||||
|
|
||||||
hydra create oauth2-client \
|
hydra create oauth2-client \
|
||||||
|
|||||||
@@ -301,21 +301,12 @@ services:
|
|||||||
- ory-net
|
- ory-net
|
||||||
|
|
||||||
init-rp:
|
init-rp:
|
||||||
image: alpine:latest
|
image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0}
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
entrypoint: ["/bin/sh", "-ec"]
|
||||||
command:
|
command:
|
||||||
- /bin/sh
|
|
||||||
- -ec
|
|
||||||
- |
|
- |
|
||||||
apk add --no-cache curl tar
|
|
||||||
HYDRA_CLI_VERSION="$${HYDRA_VERSION:-v26.2.0}"
|
|
||||||
HYDRA_CLI_VERSION="$${HYDRA_CLI_VERSION%-distroless}"
|
|
||||||
HYDRA_CLI_ARCHIVE_VERSION="$${HYDRA_CLI_VERSION#v}"
|
|
||||||
curl -fsSLo /tmp/hydra.tar.gz "https://github.com/ory/hydra/releases/download/$${HYDRA_CLI_VERSION}/hydra_$${HYDRA_CLI_ARCHIVE_VERSION}-linux_64bit.tar.gz"
|
|
||||||
tar -xzf /tmp/hydra.tar.gz -C /usr/local/bin hydra
|
|
||||||
rm /tmp/hydra.tar.gz
|
|
||||||
|
|
||||||
# Function to create or update OAuth2 client (Idempotency)
|
# Function to create or update OAuth2 client (Idempotency)
|
||||||
upsert_client() {
|
upsert_client() {
|
||||||
ID=$$1
|
ID=$$1
|
||||||
|
|||||||
@@ -77,6 +77,247 @@ baron-sso-backup-YYYYMMDD-HHMMSSZ/
|
|||||||
- 암호화 방식과 key id
|
- 암호화 방식과 key id
|
||||||
- 복구 대상 환경 제한: `same-env-only`, `staging-rehearsal`, `cross-env`
|
- 복구 대상 환경 제한: `same-env-only`, `staging-rehearsal`, `cross-env`
|
||||||
|
|
||||||
|
## Dump 대상별 복구 flow 및 영향도
|
||||||
|
|
||||||
|
`make dump`의 서비스 필터는 저장소별 복구 단위를 명확히 나누기 위한 운영 인터페이스다. 전체 재해 복구는 `DUMP_SERVICES=all`을 기본으로 하되, staging rehearsal이나 부분 장애 분석에서는 개별 대상을 분리해서 검증할 수 있다.
|
||||||
|
|
||||||
|
### 대상별 요약
|
||||||
|
|
||||||
|
| 서비스 필터 | 주요 dump 산출물 | 포함 데이터 | 복구 중요도 | 복구 영향도 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `postgres` | `postgres/baron.dump` | Baron users, tenants, membership, user_login_ids, user_groups, RP metadata, API keys, WORKS mapping/outbox, Keto outbox, consent projection 등 | 필수 | Baron control plane의 원장이다. 누락되면 사용자/테넌트/RP/WORKS 참조가 끊기고 Ory DB만 복구해도 서비스 의미가 깨진다. |
|
||||||
|
| `ory-postgres` | `postgres/globals.sql`, `postgres/ory_kratos.dump`, `postgres/ory_hydra.dump`, `postgres/ory_keto.dump` | Kratos identity/credential/session, Hydra client/consent/token state, Keto relation tuple | 필수 | 인증 주체, OAuth2/OIDC 상태, ReBAC 권한 원장이다. Baron DB와 시점이 다르면 로그인/인가/consent 불일치가 발생한다. |
|
||||||
|
| `clickhouse` | `clickhouse/baron_clickhouse/schema/*.sql`, `clickhouse/baron_clickhouse/data/*.native` | Baron audit_logs, RP usage event/aggregate 등 | 운영 정책상 필수 | 인증 자체를 막지는 않지만 감사 추적, 사용량 집계, 사고 분석 이력이 손실된다. |
|
||||||
|
| `ory-clickhouse` | `clickhouse/ory_clickhouse/schema/*.sql`, `clickhouse/ory_clickhouse/data/*.native` | Oathkeeper/Ory/Vector 접근 로그 | 운영 정책상 필수 | Ory edge 접근 로그와 장애 분석 근거가 손실된다. 인증 원장은 Postgres에 있으므로 직접 로그인 기능 영향은 제한적이다. |
|
||||||
|
| `config` | `config/env.redacted`, `config/generated-ory.*`, `config/gateway.*`, `config/compose/*` | 환경 변수 redacted snapshot, generated Ory config, gateway/Oathkeeper 설정, compose 파일 | 필수 | DB만 복구해도 secret/config가 맞지 않으면 Ory Stack, WORKS 연동, callback URL, gateway routing이 정상 기동하지 못한다. |
|
||||||
|
|
||||||
|
### `postgres`: Baron 애플리케이션 DB
|
||||||
|
|
||||||
|
Dump flow:
|
||||||
|
|
||||||
|
1. `baron_postgres` 컨테이너 실행 상태를 확인한다.
|
||||||
|
2. `${DB_NAME:-baron_sso}`를 `pg_dump -Fc`로 `postgres/baron.dump`에 저장한다.
|
||||||
|
3. `pg_stat_user_tables` 기준 row count를 `reports/baron-postgres-row-counts.txt`에 기록한다.
|
||||||
|
4. `checksums.sha256`에 dump와 report checksum을 기록한다.
|
||||||
|
|
||||||
|
Restore flow:
|
||||||
|
|
||||||
|
1. restore 전용 빈 Baron Postgres DB를 준비한다.
|
||||||
|
2. `make restore ... RESTORE_SERVICES=postgres CONFIRM_RESTORE=baron-sso`로 `pg_restore --clean --if-exists`를 실행한다.
|
||||||
|
3. 복구 후 backend migration 자동 실행은 금지하고, dump 시점의 schema version과 현재 binary가 호환되는지 먼저 확인한다.
|
||||||
|
4. Kratos identity와 Baron `users.id`의 참조 검증을 수행한다.
|
||||||
|
5. WORKS relay, Keto outbox relay는 post-restore 검증 전까지 재개하지 않는다.
|
||||||
|
|
||||||
|
영향도:
|
||||||
|
|
||||||
|
- 사용자, 테넌트, 소속, RP metadata, WORKS mapping의 기준 원장이므로 full restore에서 가장 먼저 Baron/Ory 시점 정합성을 확인해야 한다.
|
||||||
|
- Baron DB만 과거 시점으로 되돌리면 Kratos identity, Hydra consent, Keto tuple과 불일치할 수 있다.
|
||||||
|
- WORKS mapping/outbox 상태가 과거로 돌아가면 외부 WORKS와 중복 upsert/delete 후보가 생길 수 있으므로 comparison dry-run이 필수다.
|
||||||
|
|
||||||
|
검증 포인트:
|
||||||
|
|
||||||
|
- `users.id`와 Kratos `identities.id` 일치
|
||||||
|
- `tenants.parent_id`, membership, user group 참조 무결성
|
||||||
|
- RP metadata와 Hydra client 연결성
|
||||||
|
- WORKS mapping의 BaronResourceID 참조 유효성
|
||||||
|
|
||||||
|
### `ory-postgres`: Kratos/Hydra/Keto DB
|
||||||
|
|
||||||
|
Dump flow:
|
||||||
|
|
||||||
|
1. `ory_postgres` 컨테이너 실행 상태를 확인한다.
|
||||||
|
2. `pg_dumpall --globals-only`로 role/권한 정보를 `postgres/globals.sql`에 저장한다.
|
||||||
|
3. `${KRATOS_DB:-ory_kratos}`, `${HYDRA_DB:-ory_hydra}`, `${KETO_DB:-ory_keto}`를 각각 `pg_dump -Fc`로 저장한다.
|
||||||
|
4. 각 DB의 row count report를 `reports/ory_*-row-counts.txt`에 기록한다.
|
||||||
|
|
||||||
|
Restore flow:
|
||||||
|
|
||||||
|
1. restore 전용 빈 Ory Postgres DB를 준비한다.
|
||||||
|
2. 필요 시 `globals.sql`을 먼저 적용한다.
|
||||||
|
3. Kratos, Hydra, Keto DB를 각각 복구한다.
|
||||||
|
4. migration은 자동 실행하지 않고, Ory binary version과 dump schema version을 확인한다.
|
||||||
|
5. Ory Stack을 backend보다 먼저 기동해 admin/public endpoint health를 확인한다.
|
||||||
|
|
||||||
|
영향도:
|
||||||
|
|
||||||
|
- Kratos DB는 사용자 subject와 credential 원장이므로 누락되면 비밀번호, recovery/verifiable address, identity UUID 보존이 불가능하다.
|
||||||
|
- Hydra DB는 client, consent, OAuth2 token/session 상태를 담으므로 누락되면 기존 RP 로그인/consent 상태가 재생성되거나 만료될 수 있다.
|
||||||
|
- Keto DB는 ReBAC tuple 원장이므로 누락되면 사용자/테넌트/RP 권한 판단이 실패한다.
|
||||||
|
- Ory DB만 복구하고 Baron DB가 맞지 않으면 identity는 있으나 Baron user가 없거나, Baron user는 있으나 identity가 없는 상태가 된다.
|
||||||
|
|
||||||
|
검증 포인트:
|
||||||
|
|
||||||
|
- Kratos identity 수와 Baron users 수 비교
|
||||||
|
- Hydra client와 Baron RP metadata 비교
|
||||||
|
- Keto tuple subject/object가 복구된 사용자/테넌트/RP를 참조하는지 확인
|
||||||
|
- 대표 사용자 password login, 대표 RP OIDC login/consent smoke
|
||||||
|
|
||||||
|
### `clickhouse`: Baron ClickHouse
|
||||||
|
|
||||||
|
Dump flow:
|
||||||
|
|
||||||
|
1. `baron_clickhouse` 컨테이너 실행 상태를 확인한다.
|
||||||
|
2. `system.tables`에서 Baron ClickHouse table 목록과 engine을 조회한다.
|
||||||
|
3. 일반 table은 `SHOW CREATE TABLE`과 `FORMAT Native` 데이터를 함께 저장한다.
|
||||||
|
4. view 계열 engine은 restore insert 위험을 피하기 위해 schema만 저장한다.
|
||||||
|
5. table별 row count를 `reports/baron_clickhouse-row-counts.txt`에 기록한다.
|
||||||
|
|
||||||
|
Restore flow:
|
||||||
|
|
||||||
|
1. restore 전용 ClickHouse DB를 준비한다.
|
||||||
|
2. database를 생성한 뒤 table schema를 먼저 적용한다.
|
||||||
|
3. 일반 table의 `.native` 데이터를 insert한다.
|
||||||
|
4. materialized view나 view는 schema만 복구하고, 대상 table 데이터가 들어간 뒤 재계산/재생성 정책을 별도로 확인한다.
|
||||||
|
5. row count와 주요 기간의 min/max timestamp를 비교한다.
|
||||||
|
|
||||||
|
영향도:
|
||||||
|
|
||||||
|
- Baron audit log와 RP usage 집계가 손실되면 보안 감사, 운영 추적, 사용량 화면의 신뢰도가 떨어진다.
|
||||||
|
- 인증 기능 자체의 필수 원장은 아니지만, 사고 대응과 운영 증적 측면에서는 필수 백업 대상이다.
|
||||||
|
- aggregate/materialized view는 원본 event table과 복구 순서가 맞지 않으면 중복 집계나 누락 집계가 생길 수 있다.
|
||||||
|
|
||||||
|
검증 포인트:
|
||||||
|
|
||||||
|
- `audit_logs`, `rp_usage_events`, aggregate table row count 비교
|
||||||
|
- 주요 기간별 min/max timestamp 비교
|
||||||
|
- AdminFront/DevFront의 사용량 조회 smoke
|
||||||
|
|
||||||
|
### `ory-clickhouse`: Ory ClickHouse
|
||||||
|
|
||||||
|
Dump flow:
|
||||||
|
|
||||||
|
1. `ory_clickhouse` 컨테이너 실행 상태를 확인한다.
|
||||||
|
2. Ory/Oathkeeper/Vector 계열 table schema와 일반 table data를 저장한다.
|
||||||
|
3. table별 row count를 `reports/ory_clickhouse-row-counts.txt`에 기록한다.
|
||||||
|
|
||||||
|
Restore flow:
|
||||||
|
|
||||||
|
1. restore 전용 Ory ClickHouse DB를 준비한다.
|
||||||
|
2. schema를 적용한 뒤 일반 table data를 insert한다.
|
||||||
|
3. Vector/Oathkeeper 기동 후 신규 로그가 같은 table로 유입되는지 확인한다.
|
||||||
|
|
||||||
|
영향도:
|
||||||
|
|
||||||
|
- Ory edge 접근 로그와 gateway 관측 자료가 손실된다.
|
||||||
|
- 인증/인가 원장은 Postgres에 있으므로 서비스 기동 자체 영향은 제한적이다.
|
||||||
|
- 보안 사고 분석, OIDC redirect 장애 분석, rate/anomaly 분석에는 영향이 크다.
|
||||||
|
|
||||||
|
검증 포인트:
|
||||||
|
|
||||||
|
- Oathkeeper access log row count 비교
|
||||||
|
- Oathkeeper 경유 요청 후 신규 로그 유입 확인
|
||||||
|
- Vector pipeline 재기동 후 error log 확인
|
||||||
|
|
||||||
|
### `config`: 설정 snapshot
|
||||||
|
|
||||||
|
Dump flow:
|
||||||
|
|
||||||
|
1. `.env`가 있으면 secret 성격 key를 `REDACTED`로 치환해 `config/env.redacted`를 만든다.
|
||||||
|
2. `config/.generated/ory`, `gateway`, 주요 compose 파일을 tar snapshot 또는 file copy로 보존한다.
|
||||||
|
3. 실제 secret 원문은 1차 로컬 구현의 `env.redacted`에 포함하지 않는다. 운영 백업에서는 별도 암호화 산출물로 보관해야 한다.
|
||||||
|
|
||||||
|
Restore flow:
|
||||||
|
|
||||||
|
1. `make restore ... RESTORE_SERVICES=config` 실행 시 운영 파일을 직접 덮어쓰지 않는다.
|
||||||
|
2. snapshot은 `config-restored/`에 풀어 운영자가 diff로 검토한다.
|
||||||
|
3. secret 원문은 승인된 secret manager 또는 암호화 백업에서 별도 복원한다.
|
||||||
|
4. `make validate-auth-config`, `make verify-auth-config`로 callback/redirect/gateway mapping을 검증한다.
|
||||||
|
5. Ory Stack과 gateway를 기동한 뒤 대표 callback과 OIDC flow를 확인한다.
|
||||||
|
|
||||||
|
영향도:
|
||||||
|
|
||||||
|
- DB가 정상이어도 Hydra system secret, Kratos config, Oathkeeper rule, WORKS private key, callback URL이 맞지 않으면 서비스가 정상 동작하지 않는다.
|
||||||
|
- `env.redacted`만으로는 운영 복구가 완성되지 않는다. 운영 복구용 secret은 별도 암호화 저장소가 필요하다.
|
||||||
|
- config를 운영 파일에 바로 덮어쓰면 현재 환경의 도메인, callback, gateway rule을 깨뜨릴 수 있으므로 수동 검토가 기본이다.
|
||||||
|
|
||||||
|
검증 포인트:
|
||||||
|
|
||||||
|
- `make validate-auth-config`
|
||||||
|
- `make verify-auth-config`
|
||||||
|
- gateway/Oathkeeper route smoke
|
||||||
|
- WORKS credential dry-run 또는 외부 호출 차단 상태 검증
|
||||||
|
|
||||||
|
### 제외 대상의 복구 영향
|
||||||
|
|
||||||
|
| 제외 대상 | 제외 이유 | 복구 후 영향 | 운영 대응 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| Redis | cache, pending login, short code 등 휘발성 상태 | 진행 중인 login/link/short code flow가 만료되거나 재시작된다. | 사용자에게 재시도 안내, 서비스 기동 후 cache 재수렴 확인 |
|
||||||
|
| 프론트 빌드 산출물 | 소스와 이미지 태그로 재생성 가능 | 기존 정적 파일을 그대로 보존하지 않는다. | 동일 commit/image tag로 재배포 |
|
||||||
|
| 로컬 reports/coverage/test-results | 운영 원장이 아님 | 운영 서비스 영향 없음 | CI artifact나 별도 관측 저장소 기준으로 확인 |
|
||||||
|
|
||||||
|
### 전체 복구 순서와 의존성
|
||||||
|
|
||||||
|
Full restore는 다음 순서를 기본으로 한다.
|
||||||
|
|
||||||
|
1. restore 전용 빈 환경과 volume을 준비한다.
|
||||||
|
2. `config` snapshot을 `config-restored/`에 풀고 운영자가 secret/config 차이를 검토한다.
|
||||||
|
3. `ory-postgres`를 복구한다.
|
||||||
|
4. `postgres`를 복구한다.
|
||||||
|
5. `ory-clickhouse`와 `clickhouse`를 복구한다.
|
||||||
|
6. `make dump-verify`와 `make restore-verify`로 산출물 무결성을 확인한다.
|
||||||
|
7. `make validate-auth-config`, `make verify-auth-config`로 Ory/gateway 설정을 확인한다.
|
||||||
|
8. Ory Stack을 먼저 기동하고 Kratos/Hydra/Keto health를 확인한다.
|
||||||
|
9. backend와 app을 기동한다.
|
||||||
|
10. super admin login, 일반 사용자 login, 대표 RP OIDC login/consent를 확인한다.
|
||||||
|
11. WORKS comparison dry-run을 수행한다.
|
||||||
|
12. 대량 delete/upsert 위험이 없을 때 relay worker와 외부 동기화를 재개한다.
|
||||||
|
|
||||||
|
부분 복구는 원칙적으로 장애 분석 또는 rehearsal에서만 사용한다. 운영에서 특정 저장소만 과거 시점으로 되돌리면 cross-store 정합성이 깨질 수 있으므로, 실제 운영 restore는 같은 backup directory에서 나온 동일 시점의 산출물을 함께 적용하는 것을 기본으로 한다.
|
||||||
|
|
||||||
|
Restore 입력과 report:
|
||||||
|
|
||||||
|
- `make dump`, `make restore`, `make upload-cloud`는 기본적으로 Debian Trixie slim 기반 `baron-sso-backup-tools:local` 컨테이너에서 실행한다.
|
||||||
|
- 호스트 요구사항은 Docker와 Docker socket 접근 권한으로 제한한다. `zstd`, `jq`, `curl`, `openssl`, `postgresql-client`, `docker-cli`는 backup-tools image에 포함한다.
|
||||||
|
- `make restore BACKUP=<backup-dir>`는 압축 해제된 백업 디렉터리를 입력으로 사용한다.
|
||||||
|
- `make restore DUMP_FILE=<backup>.tar.zst`는 archive를 임시 디렉터리에 풀어 복구한다.
|
||||||
|
- `BACKUP`과 `DUMP_FILE`은 동시에 지정하지 않는다.
|
||||||
|
- restore report 기본 위치는 `BACKUP` 입력일 때 `<backup-dir>/reports/restore-report.json`, `DUMP_FILE` 입력일 때 `reports/restore/<archive-name>-restore-report.json`이다.
|
||||||
|
- restore report JSON이 생성되면 같은 경로에 `.md` 확장자의 Markdown 요약도 함께 생성한다.
|
||||||
|
- `RESTORE_REPORT=<path>`로 report 경로를 명시할 수 있다.
|
||||||
|
- restore report는 checksum 검증, 서비스별 복구 대상, 복구 후 row count 비교, config snapshot copy 위치를 기록한다.
|
||||||
|
- 외부 접속을 막은 상태에서 복구하려면 app/Ory serve/gateway를 먼저 중지하고 Postgres/ClickHouse/Redis만 기동해 restore를 수행한 뒤 검증 통과 후 Ory/backend/frontend를 순차 기동한다.
|
||||||
|
|
||||||
|
### 외부 분산 저장: WORKS Drive 업로드
|
||||||
|
|
||||||
|
로컬 dump가 끝난 뒤 같은 백업 디렉터리를 외부 저장소에 분산 보관하기 위해 `scripts/backup/upload_cloud.sh`를 사용한다. 현재 1차 대상은 WORKS Drive다.
|
||||||
|
|
||||||
|
Upload flow:
|
||||||
|
|
||||||
|
1. `make dump BACKUP=...` 또는 `make dump-upload-cloud BACKUP=...`로 백업 산출물을 만든다.
|
||||||
|
2. `dump.sh`가 `reports/backup-report.md`를 생성한다. report에는 사용자 수, 테넌트 수, RP 수, Hydra client 수, WORKS 관련 row count, 서비스별 수행 시간이 Markdown 표로 기록된다.
|
||||||
|
3. `upload_cloud.sh`가 백업 디렉터리를 `baron-sso-backup-*.tar.zst`로 압축한다.
|
||||||
|
4. Drive API용 access token을 확인한다.
|
||||||
|
- 우선순위: `WORKS_DRIVE_ACCESS_TOKEN`, `WORKS_DRIVE_ACCESS_TOKEN_FILE`, `WORKS_DRIVE_ACCESS_TOKEN_CMD`
|
||||||
|
- fallback 1: `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 Drive 앱 access token 갱신
|
||||||
|
- fallback 2: `WORKS_DRIVE_OAUTH_*` 서비스 계정 JWT 토큰 발급
|
||||||
|
5. WORKS Drive upload URL 생성 API를 호출한다.
|
||||||
|
6. 발급받은 upload URL에 multipart `Filedata`로 `.tar.zst` archive를 업로드한다.
|
||||||
|
7. `WORKS_DRIVE_UPLOAD_REPORTS=true`이면 대상 폴더 아래 `WORKS_DRIVE_REPORT_FOLDER_NAME` 하위 폴더를 찾거나 생성한 뒤 `reports/*.md`만 업로드한다.
|
||||||
|
- 업로드 파일명은 `backup-report-YYYYMMDD-HHMMSSZ.md`처럼 업로드 시각을 붙인다.
|
||||||
|
- `reports/cloud-upload.json`은 로컬 실행 기록이며 Drive 업로드 대상에서 제외한다.
|
||||||
|
8. 업로드 결과를 `reports/cloud-upload.json`에 기록한다.
|
||||||
|
|
||||||
|
대상 drive 선택:
|
||||||
|
|
||||||
|
| `WORKS_DRIVE_TARGET` | 필요 변수 | 업로드 URL 생성 API |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `sharedrive` | `WORKS_DRIVE_SHARED_DRIVE_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/sharedrives/{sharedriveId}/files[/<folderFileId>]` |
|
||||||
|
| `mydrive` | 선택 `WORKS_DRIVE_USER_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/users/{userId}/drive/files[/<folderFileId>]` |
|
||||||
|
| `group` | `WORKS_DRIVE_GROUP_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/groups/{groupId}/folder/files[/<folderFileId>]` |
|
||||||
|
| `sharedfolder` | `WORKS_DRIVE_USER_ID`, `WORKS_DRIVE_SHARED_FOLDER_ID`, 선택 `WORKS_DRIVE_PARENT_FILE_ID` | `/v1.0/users/{userId}/drive/sharedfolders/{sharedFolderId}/files[/<folderFileId>]` |
|
||||||
|
|
||||||
|
운영 주의:
|
||||||
|
|
||||||
|
- 업로드 archive는 `.tar.zst`로 고정한다. `zstd`가 없으면 실패해야 한다.
|
||||||
|
- Drive API는 `file` scope가 필요하다.
|
||||||
|
- `WORKS_DRIVE_PARENT_FILE_ID`는 폴더 이름이나 경로가 아니라 WORKS Drive API가 반환하는 폴더 `fileId`여야 한다.
|
||||||
|
- 계정 동기화용 `WORKS_ADMIN_OAUTH_*`와 백업 업로드용 `WORKS_DRIVE_OAUTH_*`는 용도가 다른 앱/키로 분리한다.
|
||||||
|
- 운영 기본 경로는 Drive용 access token을 명시 주입하거나 `WORKS_DRIVE_OAUTH_REFRESH_TOKEN`으로 갱신하는 방식이다.
|
||||||
|
- 서비스 계정 JWT fallback은 Drive 업로드 앱 정책에서 Drive scope 위임이 허용된 경우에만 성공한다.
|
||||||
|
- 파일 크기가 WORKS Drive 단일 파일 제한에 걸릴 수 있으면 `WORKS_DRIVE_MAX_SINGLE_FILE_BYTES` 또는 `WORKS_DRIVE_FORCE_SPLIT=true`로 split part 업로드를 사용한다.
|
||||||
|
- Markdown report 업로드 기본 폴더명은 `reports`이며 `WORKS_DRIVE_REPORT_FOLDER_NAME`으로 바꿀 수 있다.
|
||||||
|
- 외부 업로드 성공은 복구 가능성을 보장하지 않는다. 업로드 후에도 `make dump-verify BACKUP=...`와 restore rehearsal을 별도로 수행해야 한다.
|
||||||
|
|
||||||
## 백업 모드
|
## 백업 모드
|
||||||
|
|
||||||
### 1. Offline backup
|
### 1. Offline backup
|
||||||
|
|||||||
15
scripts/backup/dump-list.sh
Executable file
15
scripts/backup/dump-list.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/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)"
|
||||||
|
backup_root="${BACKUP_ROOT:-$repo_root/backups}"
|
||||||
|
|
||||||
|
if [[ ! -d "$backup_root" ]]; then
|
||||||
|
backup_log "No backup directory found: $backup_root"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "$backup_root" -maxdepth 1 -type d -name 'baron-sso-backup-*' | sort
|
||||||
69
scripts/backup/dump.sh
Executable file
69
scripts/backup/dump.sh
Executable file
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$script_dir/lib/common.sh"
|
||||||
|
source "$script_dir/lib/manifest.sh"
|
||||||
|
source "$script_dir/lib/postgres.sh"
|
||||||
|
source "$script_dir/lib/clickhouse.sh"
|
||||||
|
source "$script_dir/lib/config.sh"
|
||||||
|
source "$script_dir/lib/report.sh"
|
||||||
|
|
||||||
|
repo_root="$(backup_repo_root)"
|
||||||
|
services="$(normalize_service_filter "${DUMP_SERVICES:-all}")"
|
||||||
|
mode="${DUMP_MODE:-maintenance}"
|
||||||
|
backup_root="${BACKUP_ROOT:-$repo_root/backups}"
|
||||||
|
backup_dir="${BACKUP:-$backup_root/baron-sso-backup-$(backup_timestamp)}"
|
||||||
|
|
||||||
|
mkdir -p "$backup_dir/reports"
|
||||||
|
create_manifest "$backup_dir" "$mode" "$services"
|
||||||
|
service_timings_json="[]"
|
||||||
|
|
||||||
|
run_backup_step() {
|
||||||
|
local service="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
local started_at
|
||||||
|
local finished_at
|
||||||
|
local duration_seconds
|
||||||
|
|
||||||
|
started_at="$(date +%s)"
|
||||||
|
"$@"
|
||||||
|
finished_at="$(date +%s)"
|
||||||
|
duration_seconds="$((finished_at - started_at))"
|
||||||
|
service_timings_json="$(jq -c \
|
||||||
|
--arg service "$service" \
|
||||||
|
--argjson duration "$duration_seconds" \
|
||||||
|
'. + [{service:$service, duration_seconds:$duration}]' \
|
||||||
|
<<<"$service_timings_json")"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_log "Creating backup at $backup_dir"
|
||||||
|
backup_log "Backup mode: $mode"
|
||||||
|
backup_log "Services: $services"
|
||||||
|
|
||||||
|
if service_enabled postgres "$services"; then
|
||||||
|
run_backup_step postgres dump_baron_postgres "$backup_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled ory-postgres "$services"; then
|
||||||
|
run_backup_step ory-postgres dump_ory_postgres "$backup_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled clickhouse "$services"; then
|
||||||
|
run_backup_step clickhouse dump_baron_clickhouse "$backup_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled ory-clickhouse "$services"; then
|
||||||
|
run_backup_step ory-clickhouse dump_ory_clickhouse "$backup_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled config "$services"; then
|
||||||
|
run_backup_step config dump_config_snapshot "$backup_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
write_backup_markdown_report "$backup_dir" "succeeded" "$services" "$service_timings_json"
|
||||||
|
backup_checksum_file "$backup_dir"
|
||||||
|
BACKUP="$backup_dir" "$script_dir/verify-dump.sh"
|
||||||
|
|
||||||
|
backup_log "Backup complete: $backup_dir"
|
||||||
115
scripts/backup/lib/clickhouse.sh
Normal file
115
scripts/backup/lib/clickhouse.sh
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
clickhouse_query() {
|
||||||
|
local container="$1"
|
||||||
|
local user="$2"
|
||||||
|
local password="$3"
|
||||||
|
local query="$4"
|
||||||
|
|
||||||
|
docker exec "$container" clickhouse-client --user "$user" --password "$password" --query "$query"
|
||||||
|
}
|
||||||
|
|
||||||
|
render_clickhouse_schema() {
|
||||||
|
local schema_file="$1"
|
||||||
|
|
||||||
|
if head -n 1 "$schema_file" | grep -q '\\n'; then
|
||||||
|
perl -0pe 's{\\n}{\n}g; s{\\t}{\t}g; s{\\\x27}{\x27}g' "$schema_file"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat "$schema_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
dump_clickhouse_container() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local service_name="$2"
|
||||||
|
local container="$3"
|
||||||
|
local user="$4"
|
||||||
|
local password="$5"
|
||||||
|
local output_dir="$backup_dir/clickhouse/$service_name"
|
||||||
|
local table_list="$output_dir/tables.tsv"
|
||||||
|
local database
|
||||||
|
local table
|
||||||
|
local engine
|
||||||
|
local safe_name
|
||||||
|
|
||||||
|
backup_require_command docker
|
||||||
|
backup_require_container "$container"
|
||||||
|
mkdir -p "$output_dir/schema" "$output_dir/data" "$backup_dir/reports"
|
||||||
|
|
||||||
|
backup_log "Dumping ClickHouse metadata and Native data: $container"
|
||||||
|
clickhouse_query "$container" "$user" "$password" \
|
||||||
|
"select database, name, engine from system.tables where database not in ('INFORMATION_SCHEMA','information_schema','system') order by database, if(positionCaseInsensitive(engine, 'View') > 0, 1, 0), name format TSV" \
|
||||||
|
>"$table_list"
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r database table engine; do
|
||||||
|
[[ -n "$database" && -n "$table" ]] || continue
|
||||||
|
safe_name="${database}__${table}"
|
||||||
|
clickhouse_query "$container" "$user" "$password" "show create table \`${database}\`.\`${table}\` FORMAT RawBLOB" >"$output_dir/schema/${safe_name}.sql"
|
||||||
|
if [[ "$engine" != *View* ]]; then
|
||||||
|
clickhouse_query "$container" "$user" "$password" "select * from \`${database}\`.\`${table}\` format Native" >"$output_dir/data/${safe_name}.native"
|
||||||
|
fi
|
||||||
|
clickhouse_query "$container" "$user" "$password" "select '${database}.${table}:' || toString(count()) from \`${database}\`.\`${table}\`" \
|
||||||
|
>>"$backup_dir/reports/${service_name}-row-counts.txt"
|
||||||
|
done <"$table_list"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_clickhouse_container() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local service_name="$2"
|
||||||
|
local container="$3"
|
||||||
|
local user="$4"
|
||||||
|
local password="$5"
|
||||||
|
local input_dir="$backup_dir/clickhouse/$service_name"
|
||||||
|
local table_list="$input_dir/tables.tsv"
|
||||||
|
local database
|
||||||
|
local table
|
||||||
|
local engine
|
||||||
|
local safe_name
|
||||||
|
local restored_databases=""
|
||||||
|
|
||||||
|
backup_require_command docker
|
||||||
|
backup_require_container "$container"
|
||||||
|
backup_require_path "$table_list"
|
||||||
|
|
||||||
|
backup_log "Restoring ClickHouse tables: $container"
|
||||||
|
while IFS=$'\t' read -r database table engine; do
|
||||||
|
[[ -n "$database" && -n "$table" ]] || continue
|
||||||
|
if ! grep -qw -- "$database" <<<"$restored_databases"; then
|
||||||
|
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||||
|
--query "drop database if exists \`${database}\`"
|
||||||
|
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||||
|
--query "create database if not exists \`${database}\`"
|
||||||
|
restored_databases="${restored_databases:+$restored_databases }$database"
|
||||||
|
fi
|
||||||
|
done <"$table_list"
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r database table engine; do
|
||||||
|
[[ -n "$database" && -n "$table" ]] || continue
|
||||||
|
safe_name="${database}__${table}"
|
||||||
|
backup_require_path "$input_dir/schema/${safe_name}.sql"
|
||||||
|
render_clickhouse_schema "$input_dir/schema/${safe_name}.sql" \
|
||||||
|
| docker exec -i "$container" clickhouse-client --user "$user" --password "$password" --multiquery
|
||||||
|
if [[ "$engine" != *View* ]]; then
|
||||||
|
backup_require_path "$input_dir/data/${safe_name}.native"
|
||||||
|
docker exec -i "$container" clickhouse-client --user "$user" --password "$password" \
|
||||||
|
--query "insert into \`${database}\`.\`${table}\` format Native" <"$input_dir/data/${safe_name}.native"
|
||||||
|
fi
|
||||||
|
done <"$table_list"
|
||||||
|
}
|
||||||
|
|
||||||
|
dump_baron_clickhouse() {
|
||||||
|
dump_clickhouse_container "$1" "baron_clickhouse" "baron_clickhouse" "${CLICKHOUSE_USER:-baron}" "${CLICKHOUSE_PASSWORD:-password}"
|
||||||
|
}
|
||||||
|
|
||||||
|
dump_ory_clickhouse() {
|
||||||
|
dump_clickhouse_container "$1" "ory_clickhouse" "ory_clickhouse" "${ORY_CLICKHOUSE_USER:-ory}" "${ORY_CLICKHOUSE_PASSWORD:-orypass}"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_baron_clickhouse() {
|
||||||
|
restore_clickhouse_container "$1" "baron_clickhouse" "baron_clickhouse" "${CLICKHOUSE_USER:-baron}" "${CLICKHOUSE_PASSWORD:-password}"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_ory_clickhouse() {
|
||||||
|
restore_clickhouse_container "$1" "ory_clickhouse" "ory_clickhouse" "${ORY_CLICKHOUSE_USER:-ory}" "${ORY_CLICKHOUSE_PASSWORD:-orypass}"
|
||||||
|
}
|
||||||
137
scripts/backup/lib/common.sh
Normal file
137
scripts/backup/lib/common.sh
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
BACKUP_SUPPORTED_SERVICES="postgres ory-postgres clickhouse ory-clickhouse config"
|
||||||
|
|
||||||
|
backup_repo_root() {
|
||||||
|
if [[ -n "${BACKUP_REPO_ROOT:-}" ]]; then
|
||||||
|
printf '%s\n' "$BACKUP_REPO_ROOT"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local script_dir
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
|
printf '%s\n' "$script_dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_log() {
|
||||||
|
printf '==> %s\n' "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_die() {
|
||||||
|
printf 'ERROR: %s\n' "$*" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_require_command() {
|
||||||
|
local command_name="$1"
|
||||||
|
command -v "$command_name" >/dev/null 2>&1 || backup_die "required command not found: $command_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_utc_now() {
|
||||||
|
date -u '+%Y-%m-%dT%H:%M:%SZ'
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_timestamp() {
|
||||||
|
date -u '+%Y%m%d-%H%M%SZ'
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_git_commit() {
|
||||||
|
local repo_root="$1"
|
||||||
|
git -c "safe.directory=$repo_root" -C "$repo_root" rev-parse --short=12 HEAD 2>/dev/null || printf 'unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
normalize_service_filter() {
|
||||||
|
local raw="${1:-all}"
|
||||||
|
local normalized=""
|
||||||
|
local candidate
|
||||||
|
|
||||||
|
if [[ "$raw" == "all" || -z "$raw" ]]; then
|
||||||
|
printf '%s\n' "$BACKUP_SUPPORTED_SERVICES"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
raw="${raw//,/ }"
|
||||||
|
for candidate in $raw; do
|
||||||
|
if ! grep -qw -- "$candidate" <<<"$BACKUP_SUPPORTED_SERVICES"; then
|
||||||
|
backup_die "unknown backup service: $candidate"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! grep -qw -- "$candidate" <<<"$normalized"; then
|
||||||
|
normalized="${normalized:+$normalized }$candidate"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -n "$normalized" ]] || backup_die "service filter must not be empty"
|
||||||
|
printf '%s\n' "$normalized"
|
||||||
|
}
|
||||||
|
|
||||||
|
service_enabled() {
|
||||||
|
local service="$1"
|
||||||
|
local services="$2"
|
||||||
|
local candidate
|
||||||
|
|
||||||
|
for candidate in $services; do
|
||||||
|
[[ "$candidate" == "$service" ]] && return 0
|
||||||
|
done
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_require_path() {
|
||||||
|
local path="$1"
|
||||||
|
[[ -e "$path" ]] || backup_die "required path not found: $path"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_container_running() {
|
||||||
|
local container="$1"
|
||||||
|
docker inspect -f '{{.State.Running}}' "$container" 2>/dev/null | grep -qx 'true'
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_require_container() {
|
||||||
|
local container="$1"
|
||||||
|
backup_container_running "$container" || backup_die "container is not running: $container"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_redact_env() {
|
||||||
|
local source_file="$1"
|
||||||
|
local target_file="$2"
|
||||||
|
|
||||||
|
sed -E '/^[[:space:]]*#/! s/^([^=]*(SECRET|PASSWORD|TOKEN|KEY|PRIVATE|CLIENT_SECRET|COOKIE)[^=]*)=.*/\1=REDACTED/I' "$source_file" >"$target_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_checksum_file() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
(
|
||||||
|
cd "$backup_dir"
|
||||||
|
find . -type f ! -name 'checksums.sha256' -print0 \
|
||||||
|
| sort -z \
|
||||||
|
| xargs -0 sha256sum
|
||||||
|
) >"$backup_dir/checksums.sha256"
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_verify_checksums() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
backup_require_path "$backup_dir/checksums.sha256"
|
||||||
|
(
|
||||||
|
cd "$backup_dir"
|
||||||
|
sha256sum -c checksums.sha256
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
backup_safe_tar() {
|
||||||
|
local source_path="$1"
|
||||||
|
local target_base="$2"
|
||||||
|
local source_parent
|
||||||
|
local source_name
|
||||||
|
|
||||||
|
[[ -e "$source_path" ]] || return 0
|
||||||
|
|
||||||
|
source_parent="$(dirname "$source_path")"
|
||||||
|
source_name="$(basename "$source_path")"
|
||||||
|
|
||||||
|
if command -v zstd >/dev/null 2>&1; then
|
||||||
|
tar --zstd -cf "${target_base}.tar.zst" -C "$source_parent" "$source_name"
|
||||||
|
else
|
||||||
|
tar -czf "${target_base}.tar.gz" -C "$source_parent" "$source_name"
|
||||||
|
fi
|
||||||
|
}
|
||||||
37
scripts/backup/lib/config.sh
Normal file
37
scripts/backup/lib/config.sh
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
dump_config_snapshot() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local repo_root
|
||||||
|
|
||||||
|
repo_root="$(backup_repo_root)"
|
||||||
|
mkdir -p "$backup_dir/config"
|
||||||
|
|
||||||
|
if [[ -f "$repo_root/.env" ]]; then
|
||||||
|
backup_redact_env "$repo_root/.env" "$backup_dir/config/env.redacted"
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup_safe_tar "$repo_root/config/.generated/ory" "$backup_dir/config/generated-ory"
|
||||||
|
backup_safe_tar "$repo_root/gateway" "$backup_dir/config/gateway"
|
||||||
|
|
||||||
|
mkdir -p "$backup_dir/config/compose"
|
||||||
|
for compose_file in compose.infra.yaml compose.ory.yaml docker-compose.yaml; do
|
||||||
|
if [[ -f "$repo_root/$compose_file" ]]; then
|
||||||
|
cp "$repo_root/$compose_file" "$backup_dir/config/compose/$compose_file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_config_snapshot() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local repo_root
|
||||||
|
local output_dir
|
||||||
|
|
||||||
|
repo_root="$(backup_repo_root)"
|
||||||
|
output_dir="$repo_root/config-restored"
|
||||||
|
|
||||||
|
backup_require_path "$backup_dir/config"
|
||||||
|
mkdir -p "$output_dir"
|
||||||
|
cp -R "$backup_dir/config/." "$output_dir/"
|
||||||
|
backup_log "Config snapshot was copied to $output_dir for manual review."
|
||||||
|
}
|
||||||
42
scripts/backup/lib/manifest.sh
Normal file
42
scripts/backup/lib/manifest.sh
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
create_manifest() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local mode="$2"
|
||||||
|
local services="$3"
|
||||||
|
local repo_root
|
||||||
|
local created_at
|
||||||
|
local git_commit
|
||||||
|
local service
|
||||||
|
local first=1
|
||||||
|
|
||||||
|
repo_root="$(backup_repo_root)"
|
||||||
|
created_at="$(backup_utc_now)"
|
||||||
|
git_commit="$(backup_git_commit "$repo_root")"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '{\n'
|
||||||
|
printf ' "format_version": "1",\n'
|
||||||
|
printf ' "created_at": "%s",\n' "$created_at"
|
||||||
|
printf ' "git_commit": "%s",\n' "$git_commit"
|
||||||
|
printf ' "mode": "%s",\n' "$mode"
|
||||||
|
printf ' "environment_scope": "same-env-only",\n'
|
||||||
|
printf ' "services": ['
|
||||||
|
for service in $services; do
|
||||||
|
if [[ "$first" -eq 1 ]]; then
|
||||||
|
first=0
|
||||||
|
else
|
||||||
|
printf ', '
|
||||||
|
fi
|
||||||
|
printf '"%s"' "$service"
|
||||||
|
done
|
||||||
|
printf '],\n'
|
||||||
|
printf ' "restore_policy": {\n'
|
||||||
|
printf ' "requires_empty_target": true,\n'
|
||||||
|
printf ' "requires_confirmation": "baron-sso",\n'
|
||||||
|
printf ' "auto_run_migrations": false,\n'
|
||||||
|
printf ' "works_relay_auto_resume": false\n'
|
||||||
|
printf ' }\n'
|
||||||
|
printf '}\n'
|
||||||
|
} >"$backup_dir/manifest.json"
|
||||||
|
}
|
||||||
95
scripts/backup/lib/postgres.sh
Normal file
95
scripts/backup/lib/postgres.sh
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
dump_baron_postgres() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local db_user="${DB_USER:-baron}"
|
||||||
|
local db_password="${DB_PASSWORD:-password}"
|
||||||
|
local db_name="${DB_NAME:-baron_sso}"
|
||||||
|
|
||||||
|
backup_require_command docker
|
||||||
|
backup_require_container baron_postgres
|
||||||
|
mkdir -p "$backup_dir/postgres" "$backup_dir/reports"
|
||||||
|
|
||||||
|
backup_log "Dumping Baron Postgres database: $db_name"
|
||||||
|
docker exec -e "PGPASSWORD=$db_password" baron_postgres \
|
||||||
|
pg_dump -U "$db_user" -d "$db_name" -Fc >"$backup_dir/postgres/baron.dump"
|
||||||
|
|
||||||
|
docker exec -e "PGPASSWORD=$db_password" baron_postgres \
|
||||||
|
psql -U "$db_user" -d "$db_name" -Atc "select schemaname || '.' || relname || ':' || (xpath('/row/c/text()', query_to_xml(format('select count(*) as c from %I.%I', schemaname, relname), false, true, '')))[1]::text from pg_stat_user_tables order by 1" \
|
||||||
|
>"$backup_dir/reports/baron-postgres-row-counts.txt"
|
||||||
|
}
|
||||||
|
|
||||||
|
dump_ory_postgres() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local db_user="${ORY_POSTGRES_USER:-ory}"
|
||||||
|
local db_password="${ORY_POSTGRES_PASSWORD:-secret}"
|
||||||
|
local kratos_db="${KRATOS_DB:-ory_kratos}"
|
||||||
|
local hydra_db="${HYDRA_DB:-ory_hydra}"
|
||||||
|
local keto_db="${KETO_DB:-ory_keto}"
|
||||||
|
local db_name
|
||||||
|
|
||||||
|
backup_require_command docker
|
||||||
|
backup_require_container ory_postgres
|
||||||
|
mkdir -p "$backup_dir/postgres" "$backup_dir/reports"
|
||||||
|
|
||||||
|
backup_log "Dumping Ory Postgres globals"
|
||||||
|
docker exec -e "PGPASSWORD=$db_password" ory_postgres \
|
||||||
|
pg_dumpall -U "$db_user" --globals-only >"$backup_dir/postgres/globals.sql"
|
||||||
|
|
||||||
|
for db_name in "$kratos_db" "$hydra_db" "$keto_db"; do
|
||||||
|
backup_log "Dumping Ory Postgres database: $db_name"
|
||||||
|
docker exec -e "PGPASSWORD=$db_password" ory_postgres \
|
||||||
|
pg_dump -U "$db_user" -d "$db_name" -Fc >"$backup_dir/postgres/${db_name}.dump"
|
||||||
|
docker exec -e "PGPASSWORD=$db_password" ory_postgres \
|
||||||
|
psql -U "$db_user" -d "$db_name" -Atc "select schemaname || '.' || relname || ':' || (xpath('/row/c/text()', query_to_xml(format('select count(*) as c from %I.%I', schemaname, relname), false, true, '')))[1]::text from pg_stat_user_tables order by 1" \
|
||||||
|
>"$backup_dir/reports/${db_name}-row-counts.txt"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_baron_postgres() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local db_user="${DB_USER:-baron}"
|
||||||
|
local db_password="${DB_PASSWORD:-password}"
|
||||||
|
local db_name="${DB_NAME:-baron_sso}"
|
||||||
|
|
||||||
|
backup_require_path "$backup_dir/postgres/baron.dump"
|
||||||
|
backup_require_command docker
|
||||||
|
backup_require_container baron_postgres
|
||||||
|
|
||||||
|
backup_log "Restoring Baron Postgres database: $db_name"
|
||||||
|
docker exec -i -e "PGPASSWORD=$db_password" baron_postgres \
|
||||||
|
pg_restore -U "$db_user" -d "$db_name" --clean --if-exists <"$backup_dir/postgres/baron.dump"
|
||||||
|
}
|
||||||
|
|
||||||
|
restore_ory_postgres() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local db_user="${ORY_POSTGRES_USER:-ory}"
|
||||||
|
local db_password="${ORY_POSTGRES_PASSWORD:-secret}"
|
||||||
|
local kratos_db="${KRATOS_DB:-ory_kratos}"
|
||||||
|
local hydra_db="${HYDRA_DB:-ory_hydra}"
|
||||||
|
local keto_db="${KETO_DB:-ory_keto}"
|
||||||
|
local db_name
|
||||||
|
|
||||||
|
backup_require_command docker
|
||||||
|
backup_require_container ory_postgres
|
||||||
|
|
||||||
|
for db_name in "$kratos_db" "$hydra_db" "$keto_db"; do
|
||||||
|
backup_require_path "$backup_dir/postgres/${db_name}.dump"
|
||||||
|
backup_log "Restoring Ory Postgres database: $db_name"
|
||||||
|
docker exec -i -e "PGPASSWORD=$db_password" ory_postgres \
|
||||||
|
pg_restore -U "$db_user" -d "$db_name" --clean --if-exists <"$backup_dir/postgres/${db_name}.dump"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
postgres_target_has_data() {
|
||||||
|
local container="$1"
|
||||||
|
local user="$2"
|
||||||
|
local password="$3"
|
||||||
|
local database="$4"
|
||||||
|
|
||||||
|
backup_require_command docker
|
||||||
|
backup_require_container "$container"
|
||||||
|
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||||
|
psql -U "$user" -d "$database" -Atc "select exists (select 1 from pg_tables where schemaname not in ('pg_catalog','information_schema') limit 1)" \
|
||||||
|
2>/dev/null | grep -qx 't'
|
||||||
|
}
|
||||||
142
scripts/backup/lib/report.sh
Normal file
142
scripts/backup/lib/report.sh
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
report_count_from_file() {
|
||||||
|
local file_path="$1"
|
||||||
|
local key="$2"
|
||||||
|
|
||||||
|
if [[ ! -f "$file_path" ]]; then
|
||||||
|
printf '0\n'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
awk -F: -v key="$key" '$1 == key {print $2; found=1; exit} END {if (!found) print "0"}' "$file_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
report_first_count() {
|
||||||
|
local key="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
local file_path
|
||||||
|
local count
|
||||||
|
|
||||||
|
for file_path in "$@"; do
|
||||||
|
count="$(report_count_from_file "$file_path" "$key")"
|
||||||
|
if [[ "$count" != "0" ]]; then
|
||||||
|
printf '%s\n' "$count"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
printf '0\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
write_backup_markdown_report() {
|
||||||
|
local backup_dir="$1"
|
||||||
|
local status="$2"
|
||||||
|
local services="$3"
|
||||||
|
local timings_json="${4:-[]}"
|
||||||
|
local reports_dir="$backup_dir/reports"
|
||||||
|
local output_file="$reports_dir/backup-report.md"
|
||||||
|
local created_at
|
||||||
|
local manifest_created_at="unknown"
|
||||||
|
local git_commit="unknown"
|
||||||
|
local users
|
||||||
|
local tenants
|
||||||
|
local relying_parties
|
||||||
|
local hydra_clients
|
||||||
|
local works_org_units
|
||||||
|
local works_users
|
||||||
|
local timings_table
|
||||||
|
|
||||||
|
mkdir -p "$reports_dir"
|
||||||
|
created_at="$(backup_utc_now)"
|
||||||
|
|
||||||
|
if [[ -f "$backup_dir/manifest.json" ]]; then
|
||||||
|
manifest_created_at="$(jq -r '.created_at // "unknown"' "$backup_dir/manifest.json")"
|
||||||
|
git_commit="$(jq -r '.git_commit // "unknown"' "$backup_dir/manifest.json")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
users="$(report_first_count "public.users" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||||
|
tenants="$(report_first_count "public.tenants" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||||
|
relying_parties="$(report_first_count "public.relying_parties" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||||
|
hydra_clients="$(report_first_count "public.hydra_client" "$reports_dir/ory_hydra-row-counts.txt")"
|
||||||
|
works_org_units="$(report_first_count "public.works_org_units" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||||
|
works_users="$(report_first_count "public.works_users" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||||
|
|
||||||
|
timings_table="$(jq -r '
|
||||||
|
if type != "array" or length == 0 then
|
||||||
|
"| 없음 | 0 |"
|
||||||
|
else
|
||||||
|
.[] | "| \(.service) | \(.duration_seconds) |"
|
||||||
|
end
|
||||||
|
' <<<"$timings_json")"
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '# Baron SSO Backup Report\n\n'
|
||||||
|
printf '| 항목 | 값 |\n'
|
||||||
|
printf '| --- | --- |\n'
|
||||||
|
printf '| 생성 시각 | %s |\n' "$created_at"
|
||||||
|
printf '| 백업 시각 | %s |\n' "$manifest_created_at"
|
||||||
|
printf '| 상태 | %s |\n' "$status"
|
||||||
|
printf '| 백업 경로 | `%s` |\n' "$backup_dir"
|
||||||
|
printf '| Git Commit | `%s` |\n' "$git_commit"
|
||||||
|
printf '| 서비스 | `%s` |\n\n' "$services"
|
||||||
|
printf '## 요약\n\n'
|
||||||
|
printf '| 지표 | 값 |\n'
|
||||||
|
printf '| --- | ---: |\n'
|
||||||
|
printf '| 사용자 | %s |\n' "$users"
|
||||||
|
printf '| 테넌트 | %s |\n' "$tenants"
|
||||||
|
printf '| RP | %s |\n' "$relying_parties"
|
||||||
|
printf '| Hydra Client | %s |\n' "$hydra_clients"
|
||||||
|
printf '| WORKS 조직 | %s |\n' "$works_org_units"
|
||||||
|
printf '| WORKS 사용자 | %s |\n\n' "$works_users"
|
||||||
|
printf '## 서비스별 수행 시간\n\n'
|
||||||
|
printf '| 서비스 | 초 |\n'
|
||||||
|
printf '| --- | ---: |\n'
|
||||||
|
printf '%s\n' "$timings_table"
|
||||||
|
} >"$output_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_restore_markdown_report() {
|
||||||
|
local restore_json="$1"
|
||||||
|
local output_file
|
||||||
|
|
||||||
|
[[ -f "$restore_json" ]] || return 0
|
||||||
|
output_file="${restore_json%.json}.md"
|
||||||
|
|
||||||
|
jq -r '
|
||||||
|
def services: (.services // [] | join(", "));
|
||||||
|
def verification_rows:
|
||||||
|
(.verification.target_reports // []) as $reports
|
||||||
|
| if ($reports | length) == 0 then
|
||||||
|
"| 없음 | not_run | |"
|
||||||
|
else
|
||||||
|
$reports[]
|
||||||
|
| "| \(.service) | \(.status) | \(.diff_file // "") |"
|
||||||
|
end;
|
||||||
|
"# Baron SSO Restore Report\n",
|
||||||
|
"| 항목 | 값 |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| 시작 시각 | \(.started_at // "unknown") |",
|
||||||
|
"| 종료 시각 | \(.finished_at // "unknown") |",
|
||||||
|
"| 상태 | \(.status // "unknown") |",
|
||||||
|
"| 메시지 | \(.message // "") |",
|
||||||
|
"| 입력 유형 | \(.backup_source // "unknown") |",
|
||||||
|
"| 백업 경로 | `\(.backup_dir // "")` |",
|
||||||
|
"| Dump 파일 | `\(.dump_file // "")` |",
|
||||||
|
"| 서비스 | `\(services)` |",
|
||||||
|
"",
|
||||||
|
"## 검증",
|
||||||
|
"",
|
||||||
|
"| 항목 | 상태 |",
|
||||||
|
"| --- | --- |",
|
||||||
|
"| Dump checksum | \(.verification.dump_checksum // "not_run") |",
|
||||||
|
"| 대상 row count | \(.verification.target_row_counts // "not_run") |",
|
||||||
|
"",
|
||||||
|
"## 대상별 검증 결과",
|
||||||
|
"",
|
||||||
|
"| 서비스 | 상태 | Diff |",
|
||||||
|
"| --- | --- | --- |",
|
||||||
|
verification_rows
|
||||||
|
' "$restore_json" >"$output_file"
|
||||||
|
}
|
||||||
5
scripts/backup/restore-plan.sh
Executable file
5
scripts/backup/restore-plan.sh
Executable file
@@ -0,0 +1,5 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
BACKUP="${BACKUP:-${1:-}}" DUMP_FILE="${DUMP_FILE:-}" RESTORE_REPORT="${RESTORE_REPORT:-}" "$script_dir/restore.sh" --dry-run
|
||||||
460
scripts/backup/restore.sh
Executable file
460
scripts/backup/restore.sh
Executable file
@@ -0,0 +1,460 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$script_dir/lib/common.sh"
|
||||||
|
source "$script_dir/lib/postgres.sh"
|
||||||
|
source "$script_dir/lib/clickhouse.sh"
|
||||||
|
source "$script_dir/lib/config.sh"
|
||||||
|
source "$script_dir/lib/report.sh"
|
||||||
|
|
||||||
|
dry_run=false
|
||||||
|
if [[ "${1:-}" == "--dry-run" ]]; then
|
||||||
|
dry_run=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_root="$(backup_repo_root)"
|
||||||
|
backup_input="${BACKUP:-}"
|
||||||
|
dump_file="${DUMP_FILE:-}"
|
||||||
|
backup_source="directory"
|
||||||
|
temp_extract_dir=""
|
||||||
|
report_path=""
|
||||||
|
report_started_at="$(backup_utc_now)"
|
||||||
|
report_status="started"
|
||||||
|
report_message=""
|
||||||
|
dump_checksum_status="not_run"
|
||||||
|
target_verification_status="not_run"
|
||||||
|
target_verification_reports="[]"
|
||||||
|
|
||||||
|
json_array_from_words() {
|
||||||
|
local words="$1"
|
||||||
|
if [[ -z "$words" ]]; then
|
||||||
|
printf '[]\n'
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' $words | jq -R . | jq -sc .
|
||||||
|
}
|
||||||
|
|
||||||
|
write_restore_report() {
|
||||||
|
local status="$1"
|
||||||
|
local message="${2:-}"
|
||||||
|
local finished_at
|
||||||
|
local services_json
|
||||||
|
local restore_policy_json="{}"
|
||||||
|
|
||||||
|
[[ -n "$report_path" ]] || return 0
|
||||||
|
|
||||||
|
finished_at="$(backup_utc_now)"
|
||||||
|
services_json="$(json_array_from_words "${services:-}")"
|
||||||
|
if [[ -n "${backup_dir:-}" && -f "$backup_dir/manifest.json" ]]; then
|
||||||
|
restore_policy_json="$(jq -c '.restore_policy // {}' "$backup_dir/manifest.json")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$report_path")"
|
||||||
|
jq -n \
|
||||||
|
--arg format_version "1" \
|
||||||
|
--arg started_at "$report_started_at" \
|
||||||
|
--arg finished_at "$finished_at" \
|
||||||
|
--arg status "$status" \
|
||||||
|
--arg message "$message" \
|
||||||
|
--arg backup_source "$backup_source" \
|
||||||
|
--arg backup_dir "${backup_dir:-}" \
|
||||||
|
--arg dump_file "$dump_file" \
|
||||||
|
--argjson services "$services_json" \
|
||||||
|
--arg allow_non_empty_restore "${allow_non_empty:-false}" \
|
||||||
|
--arg dry_run "$dry_run" \
|
||||||
|
--arg dump_checksum "$dump_checksum_status" \
|
||||||
|
--arg target_row_counts "$target_verification_status" \
|
||||||
|
--argjson target_reports "$target_verification_reports" \
|
||||||
|
--argjson restore_policy "$restore_policy_json" \
|
||||||
|
'{
|
||||||
|
format_version: $format_version,
|
||||||
|
started_at: $started_at,
|
||||||
|
finished_at: $finished_at,
|
||||||
|
status: $status,
|
||||||
|
message: $message,
|
||||||
|
backup_source: $backup_source,
|
||||||
|
backup_dir: $backup_dir,
|
||||||
|
dump_file: (if $dump_file == "" then null else $dump_file end),
|
||||||
|
services: $services,
|
||||||
|
allow_non_empty_restore: ($allow_non_empty_restore == "true"),
|
||||||
|
dry_run: ($dry_run == "true"),
|
||||||
|
restore_policy: $restore_policy,
|
||||||
|
verification: {
|
||||||
|
dump_checksum: $dump_checksum,
|
||||||
|
target_row_counts: $target_row_counts,
|
||||||
|
target_reports: $target_reports
|
||||||
|
}
|
||||||
|
}' >"$report_path"
|
||||||
|
write_restore_markdown_report "$report_path"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_restore_input() {
|
||||||
|
if [[ -n "$temp_extract_dir" ]]; then
|
||||||
|
rm -rf "$temp_extract_dir"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
on_restore_error() {
|
||||||
|
local exit_code=$?
|
||||||
|
write_restore_report "failed" "${report_message:-restore failed}"
|
||||||
|
cleanup_restore_input
|
||||||
|
exit "$exit_code"
|
||||||
|
}
|
||||||
|
|
||||||
|
trap on_restore_error ERR
|
||||||
|
trap cleanup_restore_input EXIT
|
||||||
|
|
||||||
|
resolve_backup_input() {
|
||||||
|
if [[ -n "$backup_input" && -n "$dump_file" ]]; then
|
||||||
|
backup_die "set only one of BACKUP or DUMP_FILE for restore."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$backup_input" ]]; then
|
||||||
|
backup_dir="$backup_input"
|
||||||
|
backup_require_path "$backup_dir"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -z "$dump_file" ]]; then
|
||||||
|
backup_die "BACKUP or DUMP_FILE is required. Example: make restore BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ CONFIRM_RESTORE=baron-sso"
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup_require_path "$dump_file"
|
||||||
|
backup_require_command tar
|
||||||
|
temp_extract_dir="$(mktemp -d /tmp/baron-sso-restore.XXXXXX)"
|
||||||
|
backup_source="dump_file"
|
||||||
|
|
||||||
|
case "$dump_file" in
|
||||||
|
*.tar.zst)
|
||||||
|
backup_require_command zstd
|
||||||
|
tar --zstd --no-same-owner -xf "$dump_file" -C "$temp_extract_dir"
|
||||||
|
;;
|
||||||
|
*.tar.gz | *.tgz)
|
||||||
|
tar -xzf "$dump_file" -C "$temp_extract_dir"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
backup_die "unsupported DUMP_FILE archive format: $dump_file"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mapfile -t manifest_files < <(find "$temp_extract_dir" -type f -name manifest.json | sort)
|
||||||
|
if [[ "${#manifest_files[@]}" -ne 1 ]]; then
|
||||||
|
backup_die "DUMP_FILE must contain exactly one backup directory with manifest.json."
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup_dir="$(dirname "${manifest_files[0]}")"
|
||||||
|
}
|
||||||
|
|
||||||
|
quote_pg_ident() {
|
||||||
|
local raw="$1"
|
||||||
|
printf '"%s"' "${raw//\"/\"\"}"
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_postgres_exact_row_counts() {
|
||||||
|
local container="$1"
|
||||||
|
local user="$2"
|
||||||
|
local password="$3"
|
||||||
|
local database="$4"
|
||||||
|
local output_file="$5"
|
||||||
|
local schema
|
||||||
|
local table
|
||||||
|
local quoted_schema
|
||||||
|
local quoted_table
|
||||||
|
local count
|
||||||
|
|
||||||
|
: >"$output_file"
|
||||||
|
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||||
|
psql -U "$user" -d "$database" -At -F $'\t' \
|
||||||
|
-c "select schemaname, tablename from pg_tables where schemaname not in ('pg_catalog','information_schema') order by 1,2" \
|
||||||
|
| while IFS=$'\t' read -r schema table; do
|
||||||
|
[[ -n "$schema" && -n "$table" ]] || continue
|
||||||
|
quoted_schema="$(quote_pg_ident "$schema")"
|
||||||
|
quoted_table="$(quote_pg_ident "$table")"
|
||||||
|
count="$(docker exec -e "PGPASSWORD=$password" "$container" \
|
||||||
|
psql -U "$user" -d "$database" -At \
|
||||||
|
-c "select count(*) from ${quoted_schema}.${quoted_table}")"
|
||||||
|
printf '%s.%s:%s\n' "$schema" "$table" "$count"
|
||||||
|
done | sort >"$output_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_postgres_dump_row_counts() {
|
||||||
|
local container="$1"
|
||||||
|
local user="$2"
|
||||||
|
local password="$3"
|
||||||
|
local database="$4"
|
||||||
|
local dump_path="$5"
|
||||||
|
local output_file="$6"
|
||||||
|
local scratch_db
|
||||||
|
local scratch_ident
|
||||||
|
|
||||||
|
backup_require_path "$dump_path"
|
||||||
|
scratch_db="${database}_restore_verify_$(date -u '+%Y%m%d%H%M%S')_$$"
|
||||||
|
scratch_ident="$(quote_pg_ident "$scratch_db")"
|
||||||
|
|
||||||
|
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||||
|
psql -U "$user" -d postgres -v ON_ERROR_STOP=1 \
|
||||||
|
-c "drop database if exists ${scratch_ident} with (force)" \
|
||||||
|
-c "create database ${scratch_ident}"
|
||||||
|
|
||||||
|
docker exec -i -e "PGPASSWORD=$password" "$container" \
|
||||||
|
pg_restore -U "$user" -d "$scratch_db" --clean --if-exists <"$dump_path"
|
||||||
|
|
||||||
|
collect_postgres_exact_row_counts "$container" "$user" "$password" "$scratch_db" "$output_file"
|
||||||
|
|
||||||
|
docker exec -e "PGPASSWORD=$password" "$container" \
|
||||||
|
psql -U "$user" -d postgres -v ON_ERROR_STOP=1 \
|
||||||
|
-c "drop database if exists ${scratch_ident} with (force)"
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_clickhouse_exact_row_counts() {
|
||||||
|
local container="$1"
|
||||||
|
local user="$2"
|
||||||
|
local password="$3"
|
||||||
|
local table_list="$4"
|
||||||
|
local output_file="$5"
|
||||||
|
local database
|
||||||
|
local table
|
||||||
|
local engine
|
||||||
|
local count
|
||||||
|
|
||||||
|
: >"$output_file"
|
||||||
|
while IFS=$'\t' read -r database table engine; do
|
||||||
|
[[ -n "$database" && -n "$table" ]] || continue
|
||||||
|
count="$(docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||||
|
--query "select count() from \`${database}\`.\`${table}\`")"
|
||||||
|
printf '%s.%s:%s\n' "$database" "$table" "$count"
|
||||||
|
done <"$table_list" | sort >"$output_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
collect_clickhouse_native_stable_row_counts() {
|
||||||
|
local container="$1"
|
||||||
|
local user="$2"
|
||||||
|
local password="$3"
|
||||||
|
local input_dir="$4"
|
||||||
|
local output_file="$5"
|
||||||
|
local scratch_db
|
||||||
|
local database
|
||||||
|
local table
|
||||||
|
local engine
|
||||||
|
local safe_name
|
||||||
|
|
||||||
|
scratch_db="$(basename "$input_dir")_restore_verify_$(date -u '+%Y%m%d%H%M%S')_$$"
|
||||||
|
: >"$output_file"
|
||||||
|
|
||||||
|
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||||
|
--query "drop database if exists \`${scratch_db}\`"
|
||||||
|
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||||
|
--query "create database \`${scratch_db}\`"
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r database table engine; do
|
||||||
|
[[ -n "$database" && -n "$table" ]] || continue
|
||||||
|
if [[ "$engine" == *View* || "$engine" == *AggregatingMergeTree* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
safe_name="${database}__${table}"
|
||||||
|
backup_require_path "$input_dir/data/${safe_name}.native"
|
||||||
|
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||||
|
--query "create table \`${scratch_db}\`.\`${table}\` as \`${database}\`.\`${table}\`"
|
||||||
|
docker exec -i "$container" clickhouse-client --user "$user" --password "$password" \
|
||||||
|
--query "insert into \`${scratch_db}\`.\`${table}\` format Native" <"$input_dir/data/${safe_name}.native"
|
||||||
|
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||||
|
--query "select '${database}.${table}:' || toString(count()) from \`${scratch_db}\`.\`${table}\`" \
|
||||||
|
>>"$output_file"
|
||||||
|
done <"$input_dir/tables.tsv"
|
||||||
|
|
||||||
|
docker exec "$container" clickhouse-client --user "$user" --password "$password" \
|
||||||
|
--query "drop database if exists \`${scratch_db}\`"
|
||||||
|
sort -o "$output_file" "$output_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
filter_clickhouse_stable_row_counts() {
|
||||||
|
local table_list="$1"
|
||||||
|
local counts_file="$2"
|
||||||
|
local output_file="$3"
|
||||||
|
local database
|
||||||
|
local table
|
||||||
|
local engine
|
||||||
|
|
||||||
|
: >"$output_file"
|
||||||
|
while IFS=$'\t' read -r database table engine; do
|
||||||
|
[[ -n "$database" && -n "$table" ]] || continue
|
||||||
|
if [[ "$engine" == *View* || "$engine" == *AggregatingMergeTree* ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
grep -F "${database}.${table}:" "$counts_file" >>"$output_file" || true
|
||||||
|
done <"$table_list"
|
||||||
|
sort -o "$output_file" "$output_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
compare_row_count_report() {
|
||||||
|
local label="$1"
|
||||||
|
local expected_file="$2"
|
||||||
|
local actual_file="$3"
|
||||||
|
local diff_file="$4"
|
||||||
|
|
||||||
|
backup_require_path "$expected_file"
|
||||||
|
if diff -u <(sort "$expected_file") <(sort "$actual_file") >"$diff_file"; then
|
||||||
|
jq -n \
|
||||||
|
--arg label "$label" \
|
||||||
|
--arg expected "$expected_file" \
|
||||||
|
--arg actual "$actual_file" \
|
||||||
|
--arg status "passed" \
|
||||||
|
'{label:$label, expected:$expected, actual:$actual, status:$status}'
|
||||||
|
else
|
||||||
|
jq -n \
|
||||||
|
--arg label "$label" \
|
||||||
|
--arg expected "$expected_file" \
|
||||||
|
--arg actual "$actual_file" \
|
||||||
|
--arg diff "$diff_file" \
|
||||||
|
--arg status "failed" \
|
||||||
|
'{label:$label, expected:$expected, actual:$actual, diff:$diff, status:$status}'
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
verify_restored_targets() {
|
||||||
|
local report_dir
|
||||||
|
local report_items=()
|
||||||
|
local item
|
||||||
|
local expected
|
||||||
|
local actual
|
||||||
|
local diff_file
|
||||||
|
local db_name
|
||||||
|
|
||||||
|
report_dir="$(dirname "$report_path")/restore-targets-$(date -u '+%Y%m%d-%H%M%SZ')"
|
||||||
|
mkdir -p "$report_dir"
|
||||||
|
|
||||||
|
if service_enabled postgres "$services"; then
|
||||||
|
expected="$report_dir/baron-postgres-expected-row-counts.txt"
|
||||||
|
actual="$report_dir/baron-postgres-row-counts.txt"
|
||||||
|
diff_file="$report_dir/baron-postgres-row-counts.diff"
|
||||||
|
collect_postgres_dump_row_counts baron_postgres "${DB_USER:-baron}" "${DB_PASSWORD:-password}" "${DB_NAME:-baron_sso}" "$backup_dir/postgres/baron.dump" "$expected"
|
||||||
|
collect_postgres_exact_row_counts baron_postgres "${DB_USER:-baron}" "${DB_PASSWORD:-password}" "${DB_NAME:-baron_sso}" "$actual"
|
||||||
|
item="$(compare_row_count_report "postgres" "$expected" "$actual" "$diff_file")"
|
||||||
|
report_items+=("$item")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled ory-postgres "$services"; then
|
||||||
|
for db_name in "${KRATOS_DB:-ory_kratos}" "${HYDRA_DB:-ory_hydra}" "${KETO_DB:-ory_keto}"; do
|
||||||
|
expected="$report_dir/${db_name}-expected-row-counts.txt"
|
||||||
|
actual="$report_dir/${db_name}-row-counts.txt"
|
||||||
|
diff_file="$report_dir/${db_name}-row-counts.diff"
|
||||||
|
collect_postgres_dump_row_counts ory_postgres "${ORY_POSTGRES_USER:-ory}" "${ORY_POSTGRES_PASSWORD:-secret}" "$db_name" "$backup_dir/postgres/${db_name}.dump" "$expected"
|
||||||
|
collect_postgres_exact_row_counts ory_postgres "${ORY_POSTGRES_USER:-ory}" "${ORY_POSTGRES_PASSWORD:-secret}" "$db_name" "$actual"
|
||||||
|
item="$(compare_row_count_report "ory-postgres/$db_name" "$expected" "$actual" "$diff_file")"
|
||||||
|
report_items+=("$item")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled clickhouse "$services"; then
|
||||||
|
expected="$report_dir/baron_clickhouse-stable-expected-row-counts.txt"
|
||||||
|
actual="$report_dir/baron_clickhouse-stable-row-counts.txt"
|
||||||
|
diff_file="$report_dir/baron_clickhouse-row-counts.diff"
|
||||||
|
collect_clickhouse_exact_row_counts baron_clickhouse "${CLICKHOUSE_USER:-baron}" "${CLICKHOUSE_PASSWORD:-password}" "$backup_dir/clickhouse/baron_clickhouse/tables.tsv" "$actual"
|
||||||
|
mv "$actual" "$report_dir/baron_clickhouse-row-counts.txt"
|
||||||
|
collect_clickhouse_native_stable_row_counts baron_clickhouse "${CLICKHOUSE_USER:-baron}" "${CLICKHOUSE_PASSWORD:-password}" "$backup_dir/clickhouse/baron_clickhouse" "$expected"
|
||||||
|
filter_clickhouse_stable_row_counts "$backup_dir/clickhouse/baron_clickhouse/tables.tsv" "$report_dir/baron_clickhouse-row-counts.txt" "$actual"
|
||||||
|
item="$(compare_row_count_report "clickhouse" "$expected" "$actual" "$diff_file")"
|
||||||
|
report_items+=("$item")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled ory-clickhouse "$services"; then
|
||||||
|
expected="$report_dir/ory_clickhouse-stable-expected-row-counts.txt"
|
||||||
|
actual="$report_dir/ory_clickhouse-stable-row-counts.txt"
|
||||||
|
diff_file="$report_dir/ory_clickhouse-row-counts.diff"
|
||||||
|
collect_clickhouse_exact_row_counts ory_clickhouse "${ORY_CLICKHOUSE_USER:-ory}" "${ORY_CLICKHOUSE_PASSWORD:-orypass}" "$backup_dir/clickhouse/ory_clickhouse/tables.tsv" "$actual"
|
||||||
|
mv "$actual" "$report_dir/ory_clickhouse-row-counts.txt"
|
||||||
|
collect_clickhouse_native_stable_row_counts ory_clickhouse "${ORY_CLICKHOUSE_USER:-ory}" "${ORY_CLICKHOUSE_PASSWORD:-orypass}" "$backup_dir/clickhouse/ory_clickhouse" "$expected"
|
||||||
|
filter_clickhouse_stable_row_counts "$backup_dir/clickhouse/ory_clickhouse/tables.tsv" "$report_dir/ory_clickhouse-row-counts.txt" "$actual"
|
||||||
|
item="$(compare_row_count_report "ory-clickhouse" "$expected" "$actual" "$diff_file")"
|
||||||
|
report_items+=("$item")
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled config "$services"; then
|
||||||
|
backup_require_path "$repo_root/config-restored"
|
||||||
|
item="$(jq -n \
|
||||||
|
--arg label "config" \
|
||||||
|
--arg actual "$repo_root/config-restored" \
|
||||||
|
--arg status "passed" \
|
||||||
|
'{label:$label, actual:$actual, status:$status}')"
|
||||||
|
report_items+=("$item")
|
||||||
|
fi
|
||||||
|
|
||||||
|
target_verification_reports="$(printf '%s\n' "${report_items[@]}" | jq -s '.')"
|
||||||
|
target_verification_status="passed"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_backup_input
|
||||||
|
|
||||||
|
if [[ -n "${RESTORE_REPORT:-}" ]]; then
|
||||||
|
report_path="$RESTORE_REPORT"
|
||||||
|
elif [[ "$backup_source" == "dump_file" ]]; then
|
||||||
|
archive_name="$(basename "$dump_file")"
|
||||||
|
archive_name="${archive_name%.tar.zst}"
|
||||||
|
archive_name="${archive_name%.tar.gz}"
|
||||||
|
archive_name="${archive_name%.tgz}"
|
||||||
|
report_path="$repo_root/reports/restore/${archive_name}-restore-report.json"
|
||||||
|
else
|
||||||
|
report_path="$backup_dir/reports/restore-report.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${CONFIRM_RESTORE:-}" != "baron-sso" ]]; then
|
||||||
|
backup_die "CONFIRM_RESTORE=baron-sso is required for restore."
|
||||||
|
fi
|
||||||
|
|
||||||
|
services="$(normalize_service_filter "${RESTORE_SERVICES:-all}")"
|
||||||
|
allow_non_empty="${ALLOW_NON_EMPTY_RESTORE:-false}"
|
||||||
|
|
||||||
|
if [[ "${RESTORE_TEST_NON_EMPTY:-}" == "1" && "$allow_non_empty" != "true" ]]; then
|
||||||
|
backup_die "non-empty restore target is not allowed by default. Set ALLOW_NON_EMPTY_RESTORE=true only for an approved restore rehearsal."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$dry_run" == "true" ]]; then
|
||||||
|
backup_log "Restore plan for $backup_dir"
|
||||||
|
backup_log "Services: $services"
|
||||||
|
backup_log "ALLOW_NON_EMPTY_RESTORE=$allow_non_empty"
|
||||||
|
backup_log "RESTORE_REPORT=$report_path"
|
||||||
|
write_restore_report "planned" "restore dry-run completed"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$allow_non_empty" != "true" ]]; then
|
||||||
|
if service_enabled postgres "$services" && postgres_target_has_data baron_postgres "${DB_USER:-baron}" "${DB_PASSWORD:-password}" "${DB_NAME:-baron_sso}"; then
|
||||||
|
backup_die "non-empty restore target is not allowed by default: baron_postgres/${DB_NAME:-baron_sso}"
|
||||||
|
fi
|
||||||
|
if service_enabled ory-postgres "$services" && postgres_target_has_data ory_postgres "${ORY_POSTGRES_USER:-ory}" "${ORY_POSTGRES_PASSWORD:-secret}" "${KRATOS_DB:-ory_kratos}"; then
|
||||||
|
backup_die "non-empty restore target is not allowed by default: ory_postgres/${KRATOS_DB:-ory_kratos}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
BACKUP="$backup_dir" "$script_dir/verify-dump.sh"
|
||||||
|
dump_checksum_status="passed"
|
||||||
|
|
||||||
|
if service_enabled postgres "$services"; then
|
||||||
|
restore_baron_postgres "$backup_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled ory-postgres "$services"; then
|
||||||
|
restore_ory_postgres "$backup_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled clickhouse "$services"; then
|
||||||
|
restore_baron_clickhouse "$backup_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled ory-clickhouse "$services"; then
|
||||||
|
restore_ory_clickhouse "$backup_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled config "$services"; then
|
||||||
|
restore_config_snapshot "$backup_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
verify_restored_targets
|
||||||
|
write_restore_report "succeeded" "restore completed and target row-count verification passed"
|
||||||
|
|
||||||
|
backup_log "Restore complete. Keep WORKS relay disabled until comparison dry-run passes."
|
||||||
|
backup_log "Restore report: $report_path"
|
||||||
642
scripts/backup/upload_cloud.sh
Executable file
642
scripts/backup/upload_cloud.sh
Executable file
@@ -0,0 +1,642 @@
|
|||||||
|
#!/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)"
|
||||||
|
|
||||||
|
if [[ -f "$repo_root/.env" ]]; then
|
||||||
|
env_override_keys=(
|
||||||
|
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_SPLIT_SIZE
|
||||||
|
WORKS_DRIVE_MAX_SINGLE_FILE_BYTES
|
||||||
|
WORKS_DRIVE_FORCE_SPLIT
|
||||||
|
WORKS_DRIVE_OVERWRITE
|
||||||
|
WORKS_DRIVE_DRY_RUN
|
||||||
|
WORKS_DRIVE_UPLOAD_REPORTS
|
||||||
|
WORKS_DRIVE_REPORT_FOLDER_NAME
|
||||||
|
WORKS_DRIVE_CURL_BIN
|
||||||
|
WORKS_DRIVE_ARCHIVE_DIR
|
||||||
|
WORKS_DRIVE_SHAREDRIVE_ID
|
||||||
|
WORKS_DRIVE_SHAREDRIVE_BACKUP_DIR
|
||||||
|
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_SHAREDRIVE_BACKUP_DIR
|
||||||
|
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_path="${BACKUP:-${1:-}}"
|
||||||
|
[[ -n "$backup_path" ]] || backup_die "BACKUP is required. Example: make upload-cloud BACKUP=backups/baron-sso-backup-YYYYMMDD-HHMMSSZ"
|
||||||
|
backup_require_path "$backup_path"
|
||||||
|
|
||||||
|
WORKS_DRIVE_SHARED_DRIVE_ID="${WORKS_DRIVE_SHARED_DRIVE_ID:-${WORKS_DRIVE_SHAREDRIVE_ID:-${WORKS_SHAREDRIVE_ID:-}}}"
|
||||||
|
WORKS_DRIVE_PARENT_FILE_ID="${WORKS_DRIVE_PARENT_FILE_ID:-${WORKS_DRIVE_SHAREDRIVE_BACKUP_DIR:-${WORKS_SHAREDRIVE_BACKUP_DIR:-}}}"
|
||||||
|
|
||||||
|
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}"
|
||||||
|
archive_dir="${WORKS_DRIVE_ARCHIVE_DIR:-/tmp/baron-sso-backup-upload}"
|
||||||
|
split_size="${WORKS_DRIVE_SPLIT_SIZE:-9000M}"
|
||||||
|
force_split="${WORKS_DRIVE_FORCE_SPLIT:-false}"
|
||||||
|
max_single_file_bytes="${WORKS_DRIVE_MAX_SINGLE_FILE_BYTES:-0}"
|
||||||
|
overwrite="${WORKS_DRIVE_OVERWRITE:-false}"
|
||||||
|
upload_scope="${WORKS_DRIVE_OAUTH_SCOPE:-file}"
|
||||||
|
upload_reports="${WORKS_DRIVE_UPLOAD_REPORTS:-true}"
|
||||||
|
report_folder_name="${WORKS_DRIVE_REPORT_FOLDER_NAME:-reports}"
|
||||||
|
report_dir="$backup_path/reports"
|
||||||
|
|
||||||
|
if [[ -f "$backup_path" ]]; then
|
||||||
|
report_dir="$(dirname "$backup_path")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
mkdir -p "$archive_dir" "$report_dir"
|
||||||
|
|
||||||
|
backup_require_command jq
|
||||||
|
|
||||||
|
urlencode_path() {
|
||||||
|
jq -nr --arg value "$1" '$value|@uri'
|
||||||
|
}
|
||||||
|
|
||||||
|
json_string() {
|
||||||
|
jq -nr --arg value "$1" '$value'
|
||||||
|
}
|
||||||
|
|
||||||
|
bytes_from_size() {
|
||||||
|
local raw="$1"
|
||||||
|
local number
|
||||||
|
local unit
|
||||||
|
|
||||||
|
if [[ "$raw" =~ ^([0-9]+)([KkMmGgTt]?)$ ]]; then
|
||||||
|
number="${BASH_REMATCH[1]}"
|
||||||
|
unit="${BASH_REMATCH[2]}"
|
||||||
|
case "$unit" in
|
||||||
|
K | k) printf '%s\n' $((number * 1024)) ;;
|
||||||
|
M | m) printf '%s\n' $((number * 1024 * 1024)) ;;
|
||||||
|
G | g) printf '%s\n' $((number * 1024 * 1024 * 1024)) ;;
|
||||||
|
T | t) printf '%s\n' $((number * 1024 * 1024 * 1024 * 1024)) ;;
|
||||||
|
*) printf '%s\n' "$number" ;;
|
||||||
|
esac
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup_die "invalid size value: $raw"
|
||||||
|
}
|
||||||
|
|
||||||
|
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:-}}"
|
||||||
|
local upload_endpoint
|
||||||
|
|
||||||
|
upload_endpoint="$(resolve_target_upload_endpoint "$parent_file_id")"
|
||||||
|
printf '%s/children\n' "$upload_endpoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_target_create_folder_endpoint() {
|
||||||
|
local parent_file_id="${1:-${WORKS_DRIVE_PARENT_FILE_ID:-}}"
|
||||||
|
local upload_endpoint
|
||||||
|
|
||||||
|
upload_endpoint="$(resolve_target_upload_endpoint "$parent_file_id")"
|
||||||
|
printf '%s/createfolder\n' "$upload_endpoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
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 now
|
||||||
|
local exp
|
||||||
|
local header
|
||||||
|
local payload
|
||||||
|
local signing_input
|
||||||
|
local key_file=""
|
||||||
|
local temp_key_file=""
|
||||||
|
|
||||||
|
[[ -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" || return 1
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
package_backup_path() {
|
||||||
|
local source_path="$1"
|
||||||
|
local source_name
|
||||||
|
local target_base
|
||||||
|
|
||||||
|
if [[ -f "$source_path" ]]; then
|
||||||
|
[[ "$source_path" == *.zst ]] || backup_die "file BACKUP must be a .zst archive. Pass a backup directory to package it as .tar.zst automatically."
|
||||||
|
printf '%s\n' "$source_path"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
source_name="$(basename "$source_path")"
|
||||||
|
target_base="$archive_dir/${source_name}"
|
||||||
|
|
||||||
|
backup_require_command tar
|
||||||
|
backup_require_command zstd
|
||||||
|
tar --zstd -cf "${target_base}.tar.zst" -C "$(dirname "$source_path")" "$source_name"
|
||||||
|
printf '%s\n' "${target_base}.tar.zst"
|
||||||
|
}
|
||||||
|
|
||||||
|
build_upload_file_list() {
|
||||||
|
local package_file="$1"
|
||||||
|
local file_size
|
||||||
|
local split_bytes
|
||||||
|
local split_dir
|
||||||
|
local split_prefix
|
||||||
|
|
||||||
|
backup_require_command stat
|
||||||
|
backup_require_command split
|
||||||
|
file_size="$(stat -c '%s' "$package_file")"
|
||||||
|
split_bytes="$(bytes_from_size "$split_size")"
|
||||||
|
|
||||||
|
if [[ "$force_split" == "true" || ( "$max_single_file_bytes" != "0" && "$file_size" -gt "$max_single_file_bytes" ) ]]; then
|
||||||
|
split_dir="$archive_dir/split-$(basename "$package_file")"
|
||||||
|
rm -rf "$split_dir"
|
||||||
|
mkdir -p "$split_dir"
|
||||||
|
split_prefix="$split_dir/$(basename "$package_file").part-"
|
||||||
|
split -b "$split_bytes" -d -a 4 "$package_file" "$split_prefix"
|
||||||
|
find "$split_dir" -maxdepth 1 -type f -name "$(basename "$split_prefix")*" | sort
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "$package_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_report_folder_id() {
|
||||||
|
local access_token="$1"
|
||||||
|
local children_endpoint
|
||||||
|
local create_folder_endpoint
|
||||||
|
local children_json
|
||||||
|
local folder_id
|
||||||
|
|
||||||
|
children_endpoint="$(resolve_target_children_endpoint)"
|
||||||
|
create_folder_endpoint="$(resolve_target_create_folder_endpoint)"
|
||||||
|
children_json="$(list_child_folders "$access_token" "$children_endpoint")"
|
||||||
|
folder_id="$(jq -er --arg name "$report_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" "$report_folder_name"
|
||||||
|
}
|
||||||
|
|
||||||
|
discover_markdown_reports() {
|
||||||
|
if [[ -f "$backup_path" || ! -d "$report_dir" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
find "$report_dir" -maxdepth 1 -type f -name '*.md' | sort
|
||||||
|
}
|
||||||
|
|
||||||
|
timestamp_report_file_for_upload() {
|
||||||
|
local source_file="$1"
|
||||||
|
local file_name
|
||||||
|
local base_name
|
||||||
|
local stamped_dir
|
||||||
|
local stamped_file
|
||||||
|
|
||||||
|
file_name="$(basename "$source_file")"
|
||||||
|
base_name="${file_name%.md}"
|
||||||
|
stamped_dir="$archive_dir/reports"
|
||||||
|
mkdir -p "$stamped_dir"
|
||||||
|
stamped_file="$stamped_dir/${base_name}-${report_upload_timestamp}.md"
|
||||||
|
cp "$source_file" "$stamped_file"
|
||||||
|
printf '%s\n' "$stamped_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
write_upload_report() {
|
||||||
|
local report_file="$1"
|
||||||
|
local uploaded_json="$2"
|
||||||
|
local uploaded_reports_json="${3:-[]}"
|
||||||
|
|
||||||
|
jq -n \
|
||||||
|
--arg createdAt "$(backup_utc_now)" \
|
||||||
|
--arg backup "$backup_path" \
|
||||||
|
--arg target "$target" \
|
||||||
|
--arg endpoint "$upload_endpoint" \
|
||||||
|
--argjson files "$uploaded_json" \
|
||||||
|
--argjson reportFiles "$uploaded_reports_json" \
|
||||||
|
'{
|
||||||
|
created_at: $createdAt,
|
||||||
|
backup: $backup,
|
||||||
|
target: $target,
|
||||||
|
upload_endpoint: $endpoint,
|
||||||
|
files: $files,
|
||||||
|
report_files: $reportFiles
|
||||||
|
}' >"$report_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
upload_endpoint="$(resolve_target_upload_endpoint)"
|
||||||
|
report_upload_timestamp="$(backup_timestamp)"
|
||||||
|
|
||||||
|
if [[ "$dry_run" == "true" ]]; then
|
||||||
|
backup_log "Dry run: would upload BACKUP=$backup_path to WORKS Drive target=$target endpoint=$upload_endpoint"
|
||||||
|
if [[ "$upload_reports" == "true" ]]; then
|
||||||
|
backup_log "Dry run: would upload markdown reports from $report_dir to WORKS Drive folder=$report_folder_name"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup_require_command "$curl_bin"
|
||||||
|
|
||||||
|
package_file="$(package_backup_path "$backup_path")"
|
||||||
|
mapfile -t upload_files < <(build_upload_file_list "$package_file")
|
||||||
|
access_token="$(resolve_access_token)"
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
uploaded_report_items="[]"
|
||||||
|
if [[ "$upload_reports" == "true" ]]; then
|
||||||
|
mapfile -t markdown_reports < <(discover_markdown_reports)
|
||||||
|
if [[ "${#markdown_reports[@]}" -gt 0 ]]; then
|
||||||
|
backup_log "Resolving WORKS Drive report folder: $report_folder_name"
|
||||||
|
report_folder_id="$(resolve_report_folder_id "$access_token")"
|
||||||
|
report_upload_endpoint="$(resolve_target_upload_endpoint "$report_folder_id")"
|
||||||
|
|
||||||
|
for report_path_item in "${markdown_reports[@]}"; do
|
||||||
|
backup_require_path "$report_path_item"
|
||||||
|
stamped_report_path="$(timestamp_report_file_for_upload "$report_path_item")"
|
||||||
|
backup_log "Creating WORKS Drive upload URL for $(basename "$stamped_report_path")"
|
||||||
|
upload_url="$(create_upload_url "$access_token" "$report_upload_endpoint" "$stamped_report_path")"
|
||||||
|
backup_log "Uploading $(basename "$stamped_report_path") to WORKS Drive reports folder"
|
||||||
|
upload_response="$(upload_file_to_url "$access_token" "$upload_url" "$stamped_report_path")"
|
||||||
|
upload_response_json="$(jq -c '.' <<<"${upload_response:-{}}" 2>/dev/null || printf '{}')"
|
||||||
|
uploaded_report_items="$(jq \
|
||||||
|
--arg fileName "$(basename "$stamped_report_path")" \
|
||||||
|
--arg sourcePath "$report_path_item" \
|
||||||
|
--arg filePath "$stamped_report_path" \
|
||||||
|
--argjson fileSize "$(stat -c '%s' "$stamped_report_path")" \
|
||||||
|
--arg status "uploaded" \
|
||||||
|
--arg response "$upload_response_json" \
|
||||||
|
'. + [{file_name:$fileName, source_path:$sourcePath, file_path:$filePath, file_size:$fileSize, status:$status, response:($response | fromjson? // {})}]' \
|
||||||
|
<<<"$uploaded_report_items")"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
report_file="$report_dir/cloud-upload.json"
|
||||||
|
write_upload_report "$report_file" "$uploaded_items" "$uploaded_report_items"
|
||||||
|
|
||||||
|
backup_log "Upload complete: $report_file"
|
||||||
16
scripts/backup/verify-dump.sh
Executable file
16
scripts/backup/verify-dump.sh
Executable file
@@ -0,0 +1,16 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$script_dir/lib/common.sh"
|
||||||
|
|
||||||
|
backup_dir="${BACKUP:-${1:-}}"
|
||||||
|
[[ -n "$backup_dir" ]] || backup_die "BACKUP is required."
|
||||||
|
backup_require_path "$backup_dir"
|
||||||
|
backup_require_path "$backup_dir/manifest.json"
|
||||||
|
|
||||||
|
if ! backup_verify_checksums "$backup_dir"; then
|
||||||
|
backup_die "checksum verification failed"
|
||||||
|
fi
|
||||||
|
|
||||||
|
backup_log "Dump verification passed: $backup_dir"
|
||||||
13
scripts/backup/verify-restore.sh
Executable file
13
scripts/backup/verify-restore.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
source "$script_dir/lib/common.sh"
|
||||||
|
|
||||||
|
backup_dir="${BACKUP:-${1:-}}"
|
||||||
|
[[ -n "$backup_dir" ]] || backup_die "BACKUP is required."
|
||||||
|
|
||||||
|
BACKUP="$backup_dir" "$script_dir/verify-dump.sh"
|
||||||
|
|
||||||
|
backup_log "Restore verification policy check passed."
|
||||||
|
backup_log "Run application smoke checks separately: super admin login, representative OIDC login, and WORKS comparison dry-run."
|
||||||
76
test/backup_make_targets_test.sh
Executable file
76
test/backup_make_targets_test.sh
Executable file
@@ -0,0 +1,76 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_dry_run_contains() {
|
||||||
|
local output="$1"
|
||||||
|
local expected="$2"
|
||||||
|
grep -Fq -- "$expected" <<<"$output" || fail "dry-run output must contain: $expected"
|
||||||
|
}
|
||||||
|
|
||||||
|
grep -Fq "FROM debian:trixie-slim" "$repo_root/docker/backup-tools/Dockerfile" \
|
||||||
|
|| fail "backup-tools image must be based on debian:trixie-slim."
|
||||||
|
grep -Fq "zstd" "$repo_root/docker/backup-tools/Dockerfile" \
|
||||||
|
|| fail "backup-tools image must include zstd so restore does not depend on the host."
|
||||||
|
grep -Fq "docker-cli" "$repo_root/docker/backup-tools/Dockerfile" \
|
||||||
|
|| fail "backup-tools image must include docker CLI for containerized dump/restore orchestration."
|
||||||
|
grep -Fq "perl" "$repo_root/docker/backup-tools/Dockerfile" \
|
||||||
|
|| fail "backup-tools image must include perl for legacy ClickHouse schema dump decoding."
|
||||||
|
|
||||||
|
dump_dry_run="$(
|
||||||
|
make --dry-run --always-make -C "$repo_root" dump DUMP_SERVICES="postgres,config" DUMP_MODE="maintenance" 2>&1
|
||||||
|
)"
|
||||||
|
|
||||||
|
assert_dry_run_contains "$dump_dry_run" "docker build"
|
||||||
|
assert_dry_run_contains "$dump_dry_run" "docker run"
|
||||||
|
assert_dry_run_contains "$dump_dry_run" "baron-sso-backup-tools:local"
|
||||||
|
assert_dry_run_contains "$dump_dry_run" "--env-file .env"
|
||||||
|
assert_dry_run_contains "$dump_dry_run" "/var/run/docker.sock:/var/run/docker.sock"
|
||||||
|
assert_dry_run_contains "$dump_dry_run" "/tmp:/tmp"
|
||||||
|
assert_dry_run_contains "$dump_dry_run" "scripts/backup/dump.sh"
|
||||||
|
assert_dry_run_contains "$dump_dry_run" "DUMP_SERVICES=\"postgres,config\""
|
||||||
|
assert_dry_run_contains "$dump_dry_run" "DUMP_MODE=\"maintenance\""
|
||||||
|
|
||||||
|
restore_dry_run="$(
|
||||||
|
make --dry-run --always-make -C "$repo_root" restore BACKUP="backups/example" DUMP_FILE="backups/example.tar.zst" RESTORE_SERVICES="postgres,config" CONFIRM_RESTORE="baron-sso" RESTORE_REPORT="reports/restore-report.json" 2>&1
|
||||||
|
)"
|
||||||
|
|
||||||
|
assert_dry_run_contains "$restore_dry_run" "scripts/backup/restore.sh"
|
||||||
|
assert_dry_run_contains "$restore_dry_run" "docker run"
|
||||||
|
assert_dry_run_contains "$restore_dry_run" "BACKUP=\"backups/example\""
|
||||||
|
assert_dry_run_contains "$restore_dry_run" "DUMP_FILE=\"backups/example.tar.zst\""
|
||||||
|
assert_dry_run_contains "$restore_dry_run" "RESTORE_SERVICES=\"postgres,config\""
|
||||||
|
assert_dry_run_contains "$restore_dry_run" "CONFIRM_RESTORE=\"baron-sso\""
|
||||||
|
assert_dry_run_contains "$restore_dry_run" "RESTORE_REPORT=\"reports/restore-report.json\""
|
||||||
|
|
||||||
|
for target in dump-verify restore-verify dump-list restore-plan; do
|
||||||
|
target_dry_run="$(
|
||||||
|
make --dry-run --always-make -C "$repo_root" "$target" BACKUP="backups/example" 2>&1
|
||||||
|
)"
|
||||||
|
assert_dry_run_contains "$target_dry_run" "scripts/backup/"
|
||||||
|
done
|
||||||
|
|
||||||
|
upload_dry_run="$(
|
||||||
|
make --dry-run --always-make -C "$repo_root" upload-cloud BACKUP="backups/example" WORKS_DRIVE_DRY_RUN=true 2>&1
|
||||||
|
)"
|
||||||
|
|
||||||
|
assert_dry_run_contains "$upload_dry_run" "docker run"
|
||||||
|
assert_dry_run_contains "$upload_dry_run" "scripts/backup/upload_cloud.sh"
|
||||||
|
assert_dry_run_contains "$upload_dry_run" "BACKUP=\"backups/example\""
|
||||||
|
assert_dry_run_contains "$upload_dry_run" "WORKS_DRIVE_DRY_RUN=\"true\""
|
||||||
|
|
||||||
|
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."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "CONFIRM_RESTORE=baron-sso" /tmp/baron-sso-restore-missing.out; then
|
||||||
|
fail "make restore failure must mention the required confirmation value."
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "OK: backup Makefile targets expose the expected guarded interface"
|
||||||
135
test/backup_scripts_policy_test.sh
Executable file
135
test/backup_scripts_policy_test.sh
Executable file
@@ -0,0 +1,135 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
source "$repo_root/scripts/backup/lib/common.sh"
|
||||||
|
source "$repo_root/scripts/backup/lib/manifest.sh"
|
||||||
|
source "$repo_root/scripts/backup/lib/report.sh"
|
||||||
|
|
||||||
|
assert_eq() {
|
||||||
|
local expected="$1"
|
||||||
|
local actual="$2"
|
||||||
|
local message="$3"
|
||||||
|
[[ "$actual" == "$expected" ]] || fail "$message: expected '$expected', got '$actual'"
|
||||||
|
}
|
||||||
|
|
||||||
|
all_services="$(normalize_service_filter "all")"
|
||||||
|
assert_eq "postgres ory-postgres clickhouse ory-clickhouse config" "$all_services" "all must expand to every supported service"
|
||||||
|
|
||||||
|
selected_services="$(normalize_service_filter "postgres,config")"
|
||||||
|
assert_eq "postgres config" "$selected_services" "comma-separated services must be normalized in input order"
|
||||||
|
|
||||||
|
ory_services="$(normalize_service_filter "ory-postgres,ory-clickhouse")"
|
||||||
|
assert_eq "ory-postgres ory-clickhouse" "$ory_services" "ory service filter must not expand to Baron services"
|
||||||
|
|
||||||
|
if service_enabled "postgres" "$ory_services"; then
|
||||||
|
fail "service_enabled must not match postgres inside ory-postgres"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if service_enabled "clickhouse" "$ory_services"; then
|
||||||
|
fail "service_enabled must not match clickhouse inside ory-clickhouse"
|
||||||
|
fi
|
||||||
|
|
||||||
|
service_enabled "ory-postgres" "$ory_services" || fail "service_enabled must match exact ory-postgres token"
|
||||||
|
service_enabled "ory-clickhouse" "$ory_services" || fail "service_enabled must match exact ory-clickhouse token"
|
||||||
|
|
||||||
|
grep -Fq "drop database if exists" "$repo_root/scripts/backup/lib/clickhouse.sh" \
|
||||||
|
|| fail "ClickHouse restore must drop restored databases before replaying schema/data to avoid duplicate rows or init-table conflicts."
|
||||||
|
grep -Fq "FORMAT RawBLOB" "$repo_root/scripts/backup/lib/clickhouse.sh" \
|
||||||
|
|| fail "ClickHouse dump must store SHOW CREATE TABLE as raw SQL, not escaped TSV."
|
||||||
|
grep -Fq "render_clickhouse_schema" "$repo_root/scripts/backup/lib/clickhouse.sh" \
|
||||||
|
|| fail "ClickHouse restore must route schema files through the compatibility decoder."
|
||||||
|
grep -Fq "s{\\\\n}" "$repo_root/scripts/backup/lib/clickhouse.sh" \
|
||||||
|
|| fail "ClickHouse restore must decode escaped newlines from older schema dumps."
|
||||||
|
grep -Fq "x27" "$repo_root/scripts/backup/lib/clickhouse.sh" \
|
||||||
|
|| fail "ClickHouse restore must decode escaped single quotes from older schema dumps."
|
||||||
|
grep -Fq "filter_clickhouse_stable_row_counts" "$repo_root/scripts/backup/restore.sh" \
|
||||||
|
|| fail "restore verification must not fail on unstable ClickHouse aggregate/view physical row counts."
|
||||||
|
grep -Fq "collect_clickhouse_native_stable_row_counts" "$repo_root/scripts/backup/restore.sh" \
|
||||||
|
|| fail "restore verification must derive ClickHouse expected counts from Native data files."
|
||||||
|
|
||||||
|
if normalize_service_filter "postgres,unknown" >/tmp/baron-sso-service-filter.out 2>&1; then
|
||||||
|
fail "unknown backup service must be rejected"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "unknown backup service" /tmp/baron-sso-service-filter.out; then
|
||||||
|
fail "unknown service rejection must explain the service filter problem"
|
||||||
|
fi
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d /tmp/baron-sso-backup-policy.XXXXXX)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT INT TERM
|
||||||
|
|
||||||
|
mkdir -p "$tmp_dir/reports"
|
||||||
|
create_manifest "$tmp_dir" "maintenance" "postgres config"
|
||||||
|
|
||||||
|
manifest="$tmp_dir/manifest.json"
|
||||||
|
[[ -f "$manifest" ]] || fail "create_manifest must write manifest.json"
|
||||||
|
grep -Fq '"format_version": "1"' "$manifest" || fail "manifest must include format_version"
|
||||||
|
grep -Fq '"mode": "maintenance"' "$manifest" || fail "manifest must include backup mode"
|
||||||
|
grep -Fq '"services": [' "$manifest" || fail "manifest must include service list"
|
||||||
|
grep -Fq '"git_commit":' "$manifest" || fail "manifest must include git commit"
|
||||||
|
|
||||||
|
cat >"$tmp_dir/reports/baron-postgres-row-counts.txt" <<'EOF'
|
||||||
|
public.users:228
|
||||||
|
public.tenants:266
|
||||||
|
public.relying_parties:1
|
||||||
|
EOF
|
||||||
|
cat >"$tmp_dir/reports/ory_hydra-row-counts.txt" <<'EOF'
|
||||||
|
public.hydra_client:5
|
||||||
|
EOF
|
||||||
|
write_backup_markdown_report "$tmp_dir" "succeeded" "postgres config" '[{"service":"postgres","duration_seconds":2},{"service":"config","duration_seconds":1}]'
|
||||||
|
backup_md="$tmp_dir/reports/backup-report.md"
|
||||||
|
[[ -f "$backup_md" ]] || fail "backup markdown report must be created."
|
||||||
|
grep -Fq "# Baron SSO Backup Report" "$backup_md" || fail "backup report must have a markdown title."
|
||||||
|
grep -Fq "| 사용자 | 228 |" "$backup_md" || fail "backup report must include user count."
|
||||||
|
grep -Fq "| 테넌트 | 266 |" "$backup_md" || fail "backup report must include tenant count."
|
||||||
|
grep -Fq "| RP | 1 |" "$backup_md" || fail "backup report must include RP count."
|
||||||
|
grep -Fq "| postgres | 2 |" "$backup_md" || fail "backup report must include service duration."
|
||||||
|
|
||||||
|
printf 'original\n' >"$tmp_dir/example.txt"
|
||||||
|
(cd "$tmp_dir" && sha256sum example.txt > checksums.sha256)
|
||||||
|
printf 'changed\n' >"$tmp_dir/example.txt"
|
||||||
|
|
||||||
|
if BACKUP="$tmp_dir" "$repo_root/scripts/backup/verify-dump.sh" >/tmp/baron-sso-checksum.out 2>&1; then
|
||||||
|
fail "verify-dump must fail on checksum mismatch"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "checksum verification failed" /tmp/baron-sso-checksum.out; then
|
||||||
|
fail "checksum mismatch output must be explicit"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if BACKUP="$tmp_dir" CONFIRM_RESTORE="baron-sso" RESTORE_TEST_NON_EMPTY=1 "$repo_root/scripts/backup/restore.sh" --dry-run >/tmp/baron-sso-non-empty-restore.out 2>&1; then
|
||||||
|
fail "restore must reject non-empty targets by default"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "non-empty restore target is not allowed" /tmp/baron-sso-non-empty-restore.out; then
|
||||||
|
fail "restore must explain the non-empty target guard"
|
||||||
|
fi
|
||||||
|
|
||||||
|
archive_source="$tmp_dir/archive-source"
|
||||||
|
mkdir -p "$archive_source/reports"
|
||||||
|
create_manifest "$archive_source" "maintenance" "postgres"
|
||||||
|
printf 'archive fixture\n' >"$archive_source/example.txt"
|
||||||
|
(cd "$archive_source" && sha256sum manifest.json example.txt > checksums.sha256)
|
||||||
|
archive_file="$tmp_dir/archive-source.tar.zst"
|
||||||
|
tar --zstd -cf "$archive_file" -C "$tmp_dir" archive-source
|
||||||
|
restore_report="$tmp_dir/restore-report.json"
|
||||||
|
|
||||||
|
DUMP_FILE="$archive_file" \
|
||||||
|
CONFIRM_RESTORE="baron-sso" \
|
||||||
|
RESTORE_REPORT="$restore_report" \
|
||||||
|
"$repo_root/scripts/backup/restore.sh" --dry-run >/tmp/baron-sso-dump-file-restore.out
|
||||||
|
|
||||||
|
[[ -f "$restore_report" ]] || fail "restore dry-run with DUMP_FILE must write RESTORE_REPORT."
|
||||||
|
jq -e \
|
||||||
|
--arg dump_file "$archive_file" \
|
||||||
|
'.status == "planned" and .dump_file == $dump_file and .backup_dir != null and (.services | index("postgres"))' \
|
||||||
|
"$restore_report" >/dev/null || fail "restore report must describe planned DUMP_FILE restore."
|
||||||
|
|
||||||
|
echo "OK: backup scripts enforce parser, manifest, checksum, and restore safety policies"
|
||||||
108
test/backup_upload_cloud_policy_test.sh
Executable file
108
test/backup_upload_cloud_policy_test.sh
Executable file
@@ -0,0 +1,108 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tmp_dir="$(mktemp -d /tmp/baron-sso-upload-cloud-test.XXXXXX)"
|
||||||
|
trap 'rm -rf "$tmp_dir"' EXIT INT TERM
|
||||||
|
|
||||||
|
backup_dir="$tmp_dir/baron-sso-backup-20260605-000000Z"
|
||||||
|
mkdir -p "$backup_dir/postgres" "$backup_dir/reports"
|
||||||
|
printf '{"format_version":"1"}\n' >"$backup_dir/manifest.json"
|
||||||
|
printf 'postgres dump fixture\n' >"$backup_dir/postgres/baron.dump"
|
||||||
|
printf '# Baron SSO Backup Report\n' >"$backup_dir/reports/backup-report.md"
|
||||||
|
(cd "$backup_dir" && sha256sum manifest.json postgres/baron.dump > checksums.sha256)
|
||||||
|
|
||||||
|
if "$repo_root/scripts/backup/upload_cloud.sh" >/tmp/baron-sso-upload-missing.out 2>&1; then
|
||||||
|
fail "upload_cloud.sh must require BACKUP."
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -Fq "BACKUP is required" /tmp/baron-sso-upload-missing.out; then
|
||||||
|
fail "missing BACKUP error must be explicit."
|
||||||
|
fi
|
||||||
|
|
||||||
|
curl_log="$tmp_dir/curl.log"
|
||||||
|
fake_curl="$tmp_dir/fake-curl.sh"
|
||||||
|
fake_bin="$tmp_dir/bin"
|
||||||
|
mkdir -p "$fake_bin"
|
||||||
|
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/folder-1/children)
|
||||||
|
printf '{"files":[]}'
|
||||||
|
;;
|
||||||
|
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/folder-1/createfolder)
|
||||||
|
printf '{"fileId":"reports-folder-1","fileName":"reports","fileType":"FOLDER"}'
|
||||||
|
;;
|
||||||
|
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/folder-1)
|
||||||
|
printf '{"uploadUrl":"https://upload.example.test/upload-1"}'
|
||||||
|
;;
|
||||||
|
https://www.worksapis.com/v1.0/sharedrives/shared-drive-1/files/reports-folder-1)
|
||||||
|
printf '{"uploadUrl":"https://upload.example.test/upload-report-1"}'
|
||||||
|
;;
|
||||||
|
https://upload.example.test/upload-1)
|
||||||
|
printf '{"fileId":"file-1"}'
|
||||||
|
;;
|
||||||
|
https://upload.example.test/upload-report-1)
|
||||||
|
printf '{"fileId":"report-file-1"}'
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "unexpected curl URL: $last_arg" >&2
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$fake_curl"
|
||||||
|
cat >"$fake_bin/zstd" <<'EOF'
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
cat
|
||||||
|
EOF
|
||||||
|
chmod +x "$fake_bin/zstd"
|
||||||
|
|
||||||
|
WORKS_DRIVE_ACCESS_TOKEN="test-access-token" \
|
||||||
|
WORKS_DRIVE_TARGET="sharedrive" \
|
||||||
|
WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \
|
||||||
|
WORKS_DRIVE_PARENT_FILE_ID="folder-1" \
|
||||||
|
WORKS_DRIVE_CURL_BIN="$fake_curl" \
|
||||||
|
FAKE_CURL_LOG="$curl_log" \
|
||||||
|
PATH="$fake_bin:$PATH" \
|
||||||
|
BACKUP="$backup_dir" \
|
||||||
|
"$repo_root/scripts/backup/upload_cloud.sh" >"$tmp_dir/upload.out"
|
||||||
|
|
||||||
|
grep -Fq "Upload complete" "$tmp_dir/upload.out" || fail "upload must complete with fake curl."
|
||||||
|
grep -Fq "sharedrives/shared-drive-1/files/folder-1" "$curl_log" || fail "must create upload URL for the configured shared drive folder."
|
||||||
|
grep -Fq "https://upload.example.test/upload-1" "$curl_log" || fail "must upload to the issued upload URL."
|
||||||
|
grep -Fq "Authorization: Bearer test-access-token" "$curl_log" || fail "must pass bearer token to WORKS API calls."
|
||||||
|
grep -Fq "Filedata=@" "$curl_log" || fail "must upload the packaged backup as multipart Filedata."
|
||||||
|
grep -Fq ".tar.zst" "$curl_log" || fail "backup directory uploads must be packaged as .tar.zst."
|
||||||
|
grep -Fq "createfolder" "$curl_log" || fail "must create or resolve a report subfolder."
|
||||||
|
grep -Fq "reports-folder-1" "$curl_log" || fail "must upload markdown reports to the reports folder."
|
||||||
|
grep -Eq "backup-report-[0-9]{8}-[0-9]{6}Z.md" "$curl_log" || fail "must upload timestamped backup markdown report."
|
||||||
|
if grep -Fq "cloud-upload.json" "$curl_log"; then
|
||||||
|
fail "cloud-upload.json must not be uploaded to WORKS Drive."
|
||||||
|
fi
|
||||||
|
|
||||||
|
report_file="$backup_dir/reports/cloud-upload.json"
|
||||||
|
[[ -f "$report_file" ]] || fail "upload must write reports/cloud-upload.json."
|
||||||
|
jq -e '.target == "sharedrive" and .files[0].status == "uploaded" and .report_files[0].status == "uploaded" and (.report_files[0].file_name | test("^backup-report-[0-9]{8}-[0-9]{6}Z[.]md$"))' "$report_file" >/dev/null || fail "upload report must include timestamped markdown report file status."
|
||||||
|
|
||||||
|
WORKS_DRIVE_DRY_RUN=true \
|
||||||
|
WORKS_DRIVE_TARGET="sharedrive" \
|
||||||
|
WORKS_DRIVE_SHARED_DRIVE_ID="shared-drive-1" \
|
||||||
|
WORKS_DRIVE_PARENT_FILE_ID="folder-1" \
|
||||||
|
PATH="$fake_bin:$PATH" \
|
||||||
|
BACKUP="$backup_dir" \
|
||||||
|
"$repo_root/scripts/backup/upload_cloud.sh" >"$tmp_dir/dry-run.out"
|
||||||
|
|
||||||
|
grep -Fq "Dry run" "$tmp_dir/dry-run.out" || fail "dry-run must not require a token or call curl."
|
||||||
|
|
||||||
|
echo "OK: upload_cloud uploads current backup artifacts to WORKS Drive"
|
||||||
@@ -11,7 +11,7 @@ docker_config="$(
|
|||||||
)"
|
)"
|
||||||
|
|
||||||
override_env="$(mktemp)"
|
override_env="$(mktemp)"
|
||||||
cp "$repo_root/.env" "$override_env"
|
grep -Ev '^(USERFRONT_URL|HYDRA_PUBLIC_URL|KRATOS_UI_URL|KRATOS_BROWSER_URL|ADMINFRONT_CALLBACK_URLS|DEVFRONT_CALLBACK_URLS|ORGFRONT_CALLBACK_URLS)=' "$repo_root/.env" >"$override_env"
|
||||||
cat >> "$override_env" <<'EOF'
|
cat >> "$override_env" <<'EOF'
|
||||||
USERFRONT_URL=https://compose-policy.example.test/sso
|
USERFRONT_URL=https://compose-policy.example.test/sso
|
||||||
HYDRA_PUBLIC_URL=https://compose-policy.example.test/sso/oidc
|
HYDRA_PUBLIC_URL=https://compose-policy.example.test/sso/oidc
|
||||||
@@ -112,8 +112,28 @@ root_init_rp="$(
|
|||||||
docker_init_rp="$(
|
docker_init_rp="$(
|
||||||
awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$repo_root/docker/compose.ory.yaml"
|
awk 'in_block && /^ [A-Za-z0-9_-]+:/ { exit } /^ init-rp:/ { in_block=1 } in_block { print }' "$repo_root/docker/compose.ory.yaml"
|
||||||
)"
|
)"
|
||||||
if grep -q "image: oryd/hydra" <<<"$root_init_rp$docker_init_rp"; then
|
for init_rp_file in \
|
||||||
echo "ERROR: init-rp must not use the Hydra service image because distroless tags do not provide /bin/sh." >&2
|
"$repo_root/compose.ory.yaml" \
|
||||||
|
"$repo_root/docker/compose.ory.yaml" \
|
||||||
|
"$repo_root/docker/staging_pull_compose.template.yaml" \
|
||||||
|
"$repo_root/deploy/templates/docker-compose.yaml"
|
||||||
|
do
|
||||||
|
if ! grep -Fq 'image: oryd/hydra:${HYDRA_CLI_VERSION:-v26.2.0}' "$init_rp_file"; then
|
||||||
|
echo "ERROR: init-rp must use the official Hydra CLI image with HYDRA_CLI_VERSION in $init_rp_file." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! grep -Fq 'entrypoint: ["/bin/sh", "-ec"]' "$init_rp_file"; then
|
||||||
|
echo "ERROR: init-rp must override the Hydra image entrypoint with /bin/sh -ec in $init_rp_file." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if grep -Fq 'HYDRA_CLI_ARCHIVE_VERSION' "$init_rp_file" || grep -Fq 'hydra.tar.gz' "$init_rp_file"; then
|
||||||
|
echo "ERROR: init-rp must not download Hydra CLI tarballs at runtime in $init_rp_file." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if grep -q "image: alpine:latest" <<<"$root_init_rp$docker_init_rp"; then
|
||||||
|
echo "ERROR: init-rp must not use alpine plus runtime Hydra CLI download." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user