첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
485
baron-sso/scripts/auth_config.sh
Normal file
485
baron-sso/scripts/auth_config.sh
Normal file
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUTPUT_DIR="$ROOT_DIR/config/.generated"
|
||||
OUTPUT_FILE="$OUTPUT_DIR/auth-config.env"
|
||||
MODE="${1:-build}"
|
||||
|
||||
if [[ -f "$ROOT_DIR/.env" ]]; then
|
||||
# shellcheck disable=SC1091
|
||||
set -a
|
||||
source "$ROOT_DIR/.env"
|
||||
set +a
|
||||
fi
|
||||
|
||||
USERFRONT_URL="${USERFRONT_URL:-http://localhost:5000}"
|
||||
OATHKEEPER_PUBLIC_URL="${OATHKEEPER_PUBLIC_URL:-$USERFRONT_URL}"
|
||||
HYDRA_PUBLIC_URL="${HYDRA_PUBLIC_URL:-${OATHKEEPER_PUBLIC_URL%/}/oidc}"
|
||||
HYDRA_ADMIN_URL="${HYDRA_ADMIN_URL:-http://hydra:4445}"
|
||||
KRATOS_UI_URL="${KRATOS_UI_URL:-http://localhost:5000}"
|
||||
ADMINFRONT_URL="${ADMINFRONT_URL:-https://sadmin.hmac.kr}"
|
||||
DEVFRONT_URL="${DEVFRONT_URL:-https://sdev.hmac.kr}"
|
||||
ORGFRONT_URL="${ORGFRONT_URL:-https://sorg.hmac.kr}"
|
||||
ADMINFRONT_CALLBACK_URLS="${ADMINFRONT_CALLBACK_URLS:-${ADMINFRONT_URL%/}/auth/callback}"
|
||||
DEVFRONT_CALLBACK_URLS="${DEVFRONT_CALLBACK_URLS:-${DEVFRONT_URL%/}/auth/callback}"
|
||||
ORGFRONT_CALLBACK_URLS="${ORGFRONT_CALLBACK_URLS:-${ORGFRONT_URL%/}/auth/callback}"
|
||||
KRATOS_ALLOWED_RETURN_URLS_EXTRA="${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}"
|
||||
|
||||
declare -a WARNINGS=()
|
||||
|
||||
fail() {
|
||||
echo "[auth-config] ERROR: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
warn() {
|
||||
WARNINGS+=("$1")
|
||||
}
|
||||
|
||||
validate_dotenv_line_safety() {
|
||||
local key="$1"
|
||||
local env_file="$ROOT_DIR/.env"
|
||||
[[ -f "$env_file" ]] || return 0
|
||||
|
||||
local raw_line
|
||||
raw_line="$(grep -E "^${key}=" "$env_file" | tail -n 1 || true)"
|
||||
[[ -n "$raw_line" ]] || return 0
|
||||
|
||||
if [[ "$raw_line" == *" #"* ]]; then
|
||||
fail ".env line for $key contains inline comment. Use comment-only line above the key."
|
||||
fi
|
||||
|
||||
if [[ "$raw_line" =~ [[:space:]]+$ ]]; then
|
||||
fail ".env line for $key has trailing whitespace."
|
||||
fi
|
||||
}
|
||||
|
||||
trim() {
|
||||
local value="$1"
|
||||
value="${value#"${value%%[![:space:]]*}"}"
|
||||
value="${value%"${value##*[![:space:]]}"}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
csv_to_lines() {
|
||||
local csv="$1"
|
||||
printf '%s\n' "$csv" | tr ',' '\n' | while IFS= read -r raw; do
|
||||
local item
|
||||
item="$(trim "$raw")"
|
||||
if [[ -n "$item" ]]; then
|
||||
printf '%s\n' "$item"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
list_to_lines() {
|
||||
local raw="$1"
|
||||
raw="$(trim "$raw")"
|
||||
if [[ -z "$raw" || "$raw" == "[]" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [[ "$raw" =~ ^\[(.*)\]$ ]]; then
|
||||
local inner="${BASH_REMATCH[1]}"
|
||||
inner="$(trim "$inner")"
|
||||
if [[ -z "$inner" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
printf '%s\n' "$inner" | tr ',' '\n' | while IFS= read -r token; do
|
||||
local item
|
||||
item="$(trim "$token")"
|
||||
item="${item#\"}"
|
||||
item="${item%\"}"
|
||||
item="${item#\'}"
|
||||
item="${item%\'}"
|
||||
item="$(trim "$item")"
|
||||
if [[ -n "$item" ]]; then
|
||||
printf '%s\n' "$item"
|
||||
fi
|
||||
done
|
||||
return 0
|
||||
fi
|
||||
|
||||
csv_to_lines "$raw"
|
||||
}
|
||||
|
||||
is_http_url() {
|
||||
local url="$1"
|
||||
[[ "$url" =~ ^https?://[^[:space:]]+$ ]]
|
||||
}
|
||||
|
||||
canonicalize_url() {
|
||||
local url="$1"
|
||||
if [[ "$url" =~ ^(https?://[^/]+)(/.*)?$ ]]; then
|
||||
local origin="${BASH_REMATCH[1]}"
|
||||
local path="${BASH_REMATCH[2]:-}"
|
||||
if [[ -z "$path" || "$path" == "/" ]]; then
|
||||
printf '%s' "$origin"
|
||||
return
|
||||
fi
|
||||
path="${path%/}"
|
||||
if [[ -z "$path" ]]; then
|
||||
path="/"
|
||||
fi
|
||||
printf '%s%s' "$origin" "$path"
|
||||
return
|
||||
fi
|
||||
printf '%s' "$url"
|
||||
}
|
||||
|
||||
is_origin_like_url() {
|
||||
local url="$1"
|
||||
[[ "$url" =~ ^https?://[^/]+/?$ ]]
|
||||
}
|
||||
|
||||
url_path() {
|
||||
local url="$1"
|
||||
if [[ "$url" =~ ^https?://[^/]+(/.*)?$ ]]; then
|
||||
local path="${BASH_REMATCH[1]:-/}"
|
||||
printf '%s' "$path"
|
||||
return
|
||||
fi
|
||||
printf ''
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
local value="$1"
|
||||
value="${value//\\/\\\\}"
|
||||
value="${value//\"/\\\"}"
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
join_csv() {
|
||||
local -n arr_ref=$1
|
||||
local out=""
|
||||
local first=1
|
||||
for item in "${arr_ref[@]}"; do
|
||||
if [[ $first -eq 1 ]]; then
|
||||
out="$item"
|
||||
first=0
|
||||
else
|
||||
out="$out,$item"
|
||||
fi
|
||||
done
|
||||
printf '%s' "$out"
|
||||
}
|
||||
|
||||
to_json_array() {
|
||||
local -n arr_ref=$1
|
||||
local json="["
|
||||
local first=1
|
||||
for item in "${arr_ref[@]}"; do
|
||||
local escaped
|
||||
escaped="$(json_escape "$item")"
|
||||
if [[ $first -eq 1 ]]; then
|
||||
json="$json\"$escaped\""
|
||||
first=0
|
||||
else
|
||||
json="$json,\"$escaped\""
|
||||
fi
|
||||
done
|
||||
json="$json]"
|
||||
printf '%s' "$json"
|
||||
}
|
||||
|
||||
collect_values() {
|
||||
declare -ga ADMIN_CALLBACKS=()
|
||||
declare -ga DEV_CALLBACKS=()
|
||||
declare -ga ORG_CALLBACKS=()
|
||||
declare -ga EXTRA_ALLOWED_RETURNS=()
|
||||
|
||||
while IFS= read -r item; do
|
||||
ADMIN_CALLBACKS+=("$item")
|
||||
done < <(csv_to_lines "$ADMINFRONT_CALLBACK_URLS")
|
||||
|
||||
while IFS= read -r item; do
|
||||
DEV_CALLBACKS+=("$item")
|
||||
done < <(csv_to_lines "$DEVFRONT_CALLBACK_URLS")
|
||||
|
||||
while IFS= read -r item; do
|
||||
ORG_CALLBACKS+=("$item")
|
||||
done < <(csv_to_lines "$ORGFRONT_CALLBACK_URLS")
|
||||
|
||||
while IFS= read -r item; do
|
||||
EXTRA_ALLOWED_RETURNS+=("$item")
|
||||
done < <(list_to_lines "$KRATOS_ALLOWED_RETURN_URLS_EXTRA")
|
||||
}
|
||||
|
||||
validate_urls() {
|
||||
local key="$1"
|
||||
local value="$2"
|
||||
if ! is_http_url "$value"; then
|
||||
fail "$key must be a valid http/https URL: $value"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_callback_group() {
|
||||
local group_name="$1"
|
||||
local expected_path="$2"
|
||||
shift 2
|
||||
local urls=("$@")
|
||||
|
||||
if [[ ${#urls[@]} -eq 0 ]]; then
|
||||
fail "$group_name is empty"
|
||||
fi
|
||||
|
||||
local matched_expected=0
|
||||
local has_path=0
|
||||
for url in "${urls[@]}"; do
|
||||
validate_urls "$group_name entry" "$url"
|
||||
local canonical
|
||||
canonical="$(canonicalize_url "$url")"
|
||||
if [[ "$url" != "$canonical" ]]; then
|
||||
fail "$group_name entry must not end with trailing slash: $url"
|
||||
fi
|
||||
local path
|
||||
path="$(url_path "$canonical")"
|
||||
if [[ -n "$path" && "$path" != "/" ]]; then
|
||||
has_path=1
|
||||
fi
|
||||
if [[ "$path" == "$expected_path" ]]; then
|
||||
matched_expected=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ $has_path -eq 0 ]]; then
|
||||
fail "$group_name must include path segment (callback path required)"
|
||||
fi
|
||||
|
||||
if [[ $matched_expected -eq 0 ]]; then
|
||||
warn "$group_name does not include recommended path: $expected_path"
|
||||
fi
|
||||
}
|
||||
|
||||
validate_gateway_mapping() {
|
||||
validate_urls "HYDRA_PUBLIC_URL" "$HYDRA_PUBLIC_URL"
|
||||
validate_urls "USERFRONT_URL" "$USERFRONT_URL"
|
||||
validate_urls "KRATOS_UI_URL" "$KRATOS_UI_URL"
|
||||
|
||||
local mode=""
|
||||
if [[ "$HYDRA_PUBLIC_URL" =~ ^https?://hydra(:[0-9]+)?(/|$) ]]; then
|
||||
mode="direct_match"
|
||||
else
|
||||
mode="mapped_match"
|
||||
if ! grep -Eq 'location /oidc' "$ROOT_DIR/gateway/nginx.conf"; then
|
||||
mode="unmapped_fail"
|
||||
fi
|
||||
if ! grep -Eq '"url": "<\.\*>://<(\.\*|\[\^/\]\+)>/oidc/oauth2/<\.\*>"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then
|
||||
mode="unmapped_fail"
|
||||
fi
|
||||
if ! grep -Eq '"strip_path(_prefix)?": "/oidc"' "$ROOT_DIR/docker/ory/oathkeeper/rules.json"; then
|
||||
mode="unmapped_fail"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$mode" == "unmapped_fail" ]]; then
|
||||
fail "Public/Internal Hydra URL mismatch exists but gateway mapping rules are incomplete"
|
||||
fi
|
||||
|
||||
OIDC_HYDRA_URL_MATCH_MODE="$mode"
|
||||
}
|
||||
|
||||
build_allowed_return_urls() {
|
||||
declare -ga KRATOS_ALLOWED_RETURN_URLS=()
|
||||
declare -gA _seen_allowed=()
|
||||
|
||||
add_allowed_url() {
|
||||
local candidate="$1"
|
||||
candidate="$(trim "$candidate")"
|
||||
[[ -z "$candidate" ]] && return
|
||||
validate_urls "allowed_return_url" "$candidate"
|
||||
candidate="$(canonicalize_url "$candidate")"
|
||||
if [[ -z "${_seen_allowed[$candidate]:-}" ]]; then
|
||||
KRATOS_ALLOWED_RETURN_URLS+=("$candidate")
|
||||
_seen_allowed["$candidate"]=1
|
||||
fi
|
||||
}
|
||||
|
||||
add_allowed_with_slash_variant() {
|
||||
local candidate="$1"
|
||||
add_allowed_url "$candidate"
|
||||
local normalized
|
||||
normalized="$(canonicalize_url "$candidate")"
|
||||
if is_origin_like_url "$candidate"; then
|
||||
add_allowed_url "${normalized}/"
|
||||
fi
|
||||
}
|
||||
|
||||
add_userfront_return_urls() {
|
||||
local base="$1"
|
||||
local normalized
|
||||
normalized="$(canonicalize_url "$base")"
|
||||
[[ -n "$normalized" ]] || return
|
||||
|
||||
add_allowed_with_slash_variant "$normalized"
|
||||
add_allowed_url "${normalized}/ko"
|
||||
add_allowed_url "${normalized}/ko/"
|
||||
add_allowed_url "${normalized}/en"
|
||||
add_allowed_url "${normalized}/en/"
|
||||
add_allowed_url "${normalized}/auth/callback"
|
||||
add_allowed_url "${normalized}/ko/auth/callback"
|
||||
add_allowed_url "${normalized}/en/auth/callback"
|
||||
}
|
||||
|
||||
add_allowed_with_slash_variant "$KRATOS_UI_URL"
|
||||
add_userfront_return_urls "$USERFRONT_URL"
|
||||
|
||||
for url in "${ADMIN_CALLBACKS[@]}"; do
|
||||
add_allowed_url "$url"
|
||||
done
|
||||
for url in "${DEV_CALLBACKS[@]}"; do
|
||||
add_allowed_url "$url"
|
||||
done
|
||||
for url in "${ORG_CALLBACKS[@]}"; do
|
||||
add_allowed_url "$url"
|
||||
done
|
||||
for url in "${EXTRA_ALLOWED_RETURNS[@]}"; do
|
||||
add_allowed_url "$url"
|
||||
done
|
||||
|
||||
if [[ ${#KRATOS_ALLOWED_RETURN_URLS[@]} -eq 0 ]]; then
|
||||
fail "KRATOS allowed return URL list is empty"
|
||||
fi
|
||||
}
|
||||
|
||||
write_output() {
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
local admin_csv dev_csv org_csv returns_json
|
||||
admin_csv="$(join_csv ADMIN_CALLBACKS)"
|
||||
dev_csv="$(join_csv DEV_CALLBACKS)"
|
||||
org_csv="$(join_csv ORG_CALLBACKS)"
|
||||
returns_json="$(to_json_array KRATOS_ALLOWED_RETURN_URLS)"
|
||||
|
||||
cat >"$OUTPUT_FILE" <<EOF
|
||||
# Generated by scripts/auth_config.sh
|
||||
# Do not edit manually.
|
||||
ADMINFRONT_CALLBACK_URLS=$admin_csv
|
||||
DEVFRONT_CALLBACK_URLS=$dev_csv
|
||||
ORGFRONT_CALLBACK_URLS=$org_csv
|
||||
KRATOS_ALLOWED_RETURN_URLS_JSON=$returns_json
|
||||
OIDC_HYDRA_URL_MATCH_MODE=$OIDC_HYDRA_URL_MATCH_MODE
|
||||
EOF
|
||||
}
|
||||
|
||||
validate_compose_wiring() {
|
||||
grep -Eq 'KRATOS_SELFSERVICE_ALLOWED_RETURN_URLS=\$\{KRATOS_ALLOWED_RETURN_URLS_JSON' "$ROOT_DIR/compose.ory.yaml" \
|
||||
|| fail "compose.ory.yaml is not wired to KRATOS_ALLOWED_RETURN_URLS_JSON"
|
||||
grep -Eq 'ADMINFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \
|
||||
|| fail "compose.ory.yaml is not wired to ADMINFRONT_CALLBACK_URLS"
|
||||
grep -Eq 'DEVFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \
|
||||
|| fail "compose.ory.yaml is not wired to DEVFRONT_CALLBACK_URLS"
|
||||
grep -Eq 'ORGFRONT_CALLBACK_URLS' "$ROOT_DIR/compose.ory.yaml" \
|
||||
|| fail "compose.ory.yaml is not wired to ORGFRONT_CALLBACK_URLS"
|
||||
}
|
||||
|
||||
verify_runtime_hydra_clients() {
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
warn "docker command is unavailable; runtime hydra verification skipped"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! docker ps --format '{{.Names}}' 2>/dev/null | grep -q '^ory_hydra$'; then
|
||||
warn "ory_hydra is not running; runtime hydra verification skipped"
|
||||
return
|
||||
fi
|
||||
|
||||
local admin_info dev_info org_info
|
||||
if ! admin_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" adminfront 2>/dev/null)"; then
|
||||
fail "failed to read hydra client 'adminfront' from running container"
|
||||
fi
|
||||
if ! dev_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" devfront 2>/dev/null)"; then
|
||||
fail "failed to read hydra client 'devfront' from running container"
|
||||
fi
|
||||
if ! org_info="$(docker exec ory_hydra hydra get oauth2-client --endpoint "$HYDRA_ADMIN_URL" orgfront 2>/dev/null)"; then
|
||||
fail "failed to read hydra client 'orgfront' from running container"
|
||||
fi
|
||||
|
||||
for callback in "${ADMIN_CALLBACKS[@]}"; do
|
||||
if ! grep -Fq "$callback" <<<"$admin_info"; then
|
||||
fail "adminfront hydra client does not include callback: $callback"
|
||||
fi
|
||||
done
|
||||
for callback in "${DEV_CALLBACKS[@]}"; do
|
||||
if ! grep -Fq "$callback" <<<"$dev_info"; then
|
||||
fail "devfront hydra client does not include callback: $callback"
|
||||
fi
|
||||
done
|
||||
for callback in "${ORG_CALLBACKS[@]}"; do
|
||||
if ! grep -Fq "$callback" <<<"$org_info"; then
|
||||
fail "orgfront hydra client does not include callback: $callback"
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
run_validation() {
|
||||
validate_dotenv_line_safety "USERFRONT_URL"
|
||||
validate_dotenv_line_safety "BACKEND_URL"
|
||||
validate_dotenv_line_safety "OATHKEEPER_PUBLIC_URL"
|
||||
validate_dotenv_line_safety "HYDRA_PUBLIC_URL"
|
||||
validate_dotenv_line_safety "HYDRA_ADMIN_URL"
|
||||
validate_dotenv_line_safety "KRATOS_BROWSER_URL"
|
||||
validate_dotenv_line_safety "KRATOS_UI_URL"
|
||||
validate_dotenv_line_safety "ADMINFRONT_URL"
|
||||
validate_dotenv_line_safety "DEVFRONT_URL"
|
||||
validate_dotenv_line_safety "ORGFRONT_URL"
|
||||
validate_dotenv_line_safety "ADMINFRONT_CALLBACK_URLS"
|
||||
validate_dotenv_line_safety "DEVFRONT_CALLBACK_URLS"
|
||||
validate_dotenv_line_safety "ORGFRONT_CALLBACK_URLS"
|
||||
|
||||
if [[ -n "$ADMINFRONT_URL" ]]; then
|
||||
validate_urls "ADMINFRONT_URL" "$ADMINFRONT_URL"
|
||||
fi
|
||||
if [[ -n "$DEVFRONT_URL" ]]; then
|
||||
validate_urls "DEVFRONT_URL" "$DEVFRONT_URL"
|
||||
fi
|
||||
if [[ -n "$ORGFRONT_URL" ]]; then
|
||||
validate_urls "ORGFRONT_URL" "$ORGFRONT_URL"
|
||||
fi
|
||||
|
||||
collect_values
|
||||
validate_callback_group "ADMINFRONT_CALLBACK_URLS" "/auth/callback" "${ADMIN_CALLBACKS[@]}"
|
||||
validate_callback_group "DEVFRONT_CALLBACK_URLS" "/auth/callback" "${DEV_CALLBACKS[@]}"
|
||||
validate_callback_group "ORGFRONT_CALLBACK_URLS" "/auth/callback" "${ORG_CALLBACKS[@]}"
|
||||
validate_gateway_mapping
|
||||
build_allowed_return_urls
|
||||
}
|
||||
|
||||
print_summary() {
|
||||
echo "[auth-config] mode: $MODE"
|
||||
echo "[auth-config] hydra_url_match_mode: $OIDC_HYDRA_URL_MATCH_MODE"
|
||||
echo "[auth-config] admin_callbacks: $(join_csv ADMIN_CALLBACKS)"
|
||||
echo "[auth-config] dev_callbacks: $(join_csv DEV_CALLBACKS)"
|
||||
echo "[auth-config] org_callbacks: $(join_csv ORG_CALLBACKS)"
|
||||
echo "[auth-config] kratos_allowed_return_urls_count: ${#KRATOS_ALLOWED_RETURN_URLS[@]}"
|
||||
|
||||
if [[ ${#WARNINGS[@]} -gt 0 ]]; then
|
||||
for message in "${WARNINGS[@]}"; do
|
||||
echo "[auth-config] WARN: $message"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
case "$MODE" in
|
||||
build)
|
||||
run_validation
|
||||
write_output
|
||||
print_summary
|
||||
echo "[auth-config] wrote: $OUTPUT_FILE"
|
||||
;;
|
||||
validate)
|
||||
run_validation
|
||||
print_summary
|
||||
;;
|
||||
verify)
|
||||
run_validation
|
||||
validate_compose_wiring
|
||||
verify_runtime_hydra_clients
|
||||
print_summary
|
||||
echo "[auth-config] compose wiring verified"
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported mode: $MODE (supported: build|validate|verify)"
|
||||
;;
|
||||
esac
|
||||
15
baron-sso/scripts/backup/dump-list.sh
Normal file
15
baron-sso/scripts/backup/dump-list.sh
Normal 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
baron-sso/scripts/backup/dump.sh
Normal file
69
baron-sso/scripts/backup/dump.sh
Normal 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
baron-sso/scripts/backup/lib/clickhouse.sh
Normal file
115
baron-sso/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
baron-sso/scripts/backup/lib/common.sh
Normal file
137
baron-sso/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
baron-sso/scripts/backup/lib/config.sh
Normal file
37
baron-sso/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
baron-sso/scripts/backup/lib/manifest.sh
Normal file
42
baron-sso/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"
|
||||
}
|
||||
99
baron-sso/scripts/backup/lib/postgres.sh
Normal file
99
baron-sso/scripts/backup/lib/postgres.sh
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/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"
|
||||
|
||||
docker exec -e "PGPASSWORD=$db_password" baron_postgres \
|
||||
psql -U "$db_user" -d "$db_name" -Atc "select 'public.rp_user_metadata:' || count(*) from public.rp_user_metadata union all select 'public.users.global_custom_claims:' || count(*) from public.users where metadata ? 'global_custom_claims' union all select 'public.users.global_custom_claim_types:' || count(*) from public.users where metadata ? 'global_custom_claim_types' order by 1" \
|
||||
>"$backup_dir/reports/baron-postgres-custom-claim-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'
|
||||
}
|
||||
148
baron-sso/scripts/backup/lib/report.sh
Normal file
148
baron-sso/scripts/backup/lib/report.sh
Normal file
@@ -0,0 +1,148 @@
|
||||
#!/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 rp_user_custom_claims
|
||||
local global_custom_claim_users
|
||||
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")"
|
||||
rp_user_custom_claims="$(report_first_count "public.rp_user_metadata" "$reports_dir/baron-postgres-custom-claim-counts.txt" "$reports_dir/baron-postgres-row-counts.txt")"
|
||||
global_custom_claim_users="$(report_first_count "public.users.global_custom_claims" "$reports_dir/baron-postgres-custom-claim-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 '| RP 사용자 custom claim | %s |\n' "$rp_user_custom_claims"
|
||||
printf '| 전역 custom claim 사용자 | %s |\n' "$global_custom_claim_users"
|
||||
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
baron-sso/scripts/backup/restore-plan.sh
Normal file
5
baron-sso/scripts/backup/restore-plan.sh
Normal 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
baron-sso/scripts/backup/restore.sh
Normal file
460
baron-sso/scripts/backup/restore.sh
Normal 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
baron-sso/scripts/backup/upload_cloud.sh
Normal file
642
baron-sso/scripts/backup/upload_cloud.sh
Normal 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
baron-sso/scripts/backup/verify-dump.sh
Normal file
16
baron-sso/scripts/backup/verify-dump.sh
Normal 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
baron-sso/scripts/backup/verify-restore.sh
Normal file
13
baron-sso/scripts/backup/verify-restore.sh
Normal 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."
|
||||
99
baron-sso/scripts/clear_orphan_tenant_memberships.sh
Normal file
99
baron-sso/scripts/clear_orphan_tenant_memberships.sh
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BARON_CONTAINER="${BARON_CONTAINER:-baron_postgres}"
|
||||
BARON_DB_USER="${BARON_DB_USER:-baron}"
|
||||
BARON_DB_NAME="${BARON_DB_NAME:-baron_sso}"
|
||||
KRATOS_CONTAINER="${KRATOS_CONTAINER:-ory_postgres}"
|
||||
KRATOS_DB_USER="${KRATOS_DB_USER:-ory}"
|
||||
KRATOS_DB_NAME="${KRATOS_DB_NAME:-ory_kratos}"
|
||||
CONFIRM_KRATOS_DB_MAINTENANCE="${CONFIRM_KRATOS_DB_MAINTENANCE:-}"
|
||||
MARK_IDENTITY_MIRROR_STALE="${MARK_IDENTITY_MIRROR_STALE:-false}"
|
||||
|
||||
if [[ "${CONFIRM_KRATOS_DB_MAINTENANCE}" != "baron-sso" ]]; then
|
||||
echo "ERROR: CONFIRM_KRATOS_DB_MAINTENANCE=baron-sso is required before directly updating Kratos DB." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${MARK_IDENTITY_MIRROR_STALE}" != "true" ]]; then
|
||||
echo "ERROR: MARK_IDENTITY_MIRROR_STALE=true is required after marking the Redis identity mirror stale." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
docker exec -i "${BARON_CONTAINER}" \
|
||||
psql -U "${BARON_DB_USER}" -d "${BARON_DB_NAME}" \
|
||||
< "${script_dir}/clear_orphan_user_tenant_memberships.sql"
|
||||
|
||||
active_tenant_refs="$(
|
||||
docker exec "${BARON_CONTAINER}" psql -U "${BARON_DB_USER}" -d "${BARON_DB_NAME}" -At -F $'\t' \
|
||||
-c "SELECT id, LOWER(slug) FROM tenants WHERE deleted_at IS NULL ORDER BY id"
|
||||
)"
|
||||
|
||||
docker exec -i "${KRATOS_CONTAINER}" psql -U "${KRATOS_DB_USER}" -d "${KRATOS_DB_NAME}" <<SQL
|
||||
BEGIN;
|
||||
|
||||
CREATE TEMP TABLE active_tenant_refs (
|
||||
id text NOT NULL,
|
||||
slug text NOT NULL
|
||||
) ON COMMIT DROP;
|
||||
|
||||
COPY active_tenant_refs (id, slug) FROM STDIN WITH (FORMAT text, DELIMITER E'\t');
|
||||
${active_tenant_refs}
|
||||
\.
|
||||
|
||||
WITH orphan_identities AS (
|
||||
SELECT
|
||||
i.id,
|
||||
i.traits->>'email' AS email,
|
||||
i.traits->>'tenant_id' AS tenant_id,
|
||||
i.traits->>'companyCode' AS company_code,
|
||||
i.traits->'companyCodes' AS company_codes
|
||||
FROM identities AS i
|
||||
WHERE (
|
||||
COALESCE(i.traits->>'tenant_id', '') <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM active_tenant_refs AS refs
|
||||
WHERE refs.id = i.traits->>'tenant_id'
|
||||
)
|
||||
)
|
||||
OR (
|
||||
COALESCE(i.traits->>'companyCode', '') <> ''
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM active_tenant_refs AS refs
|
||||
WHERE refs.slug = LOWER(BTRIM(i.traits->>'companyCode'))
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM JSONB_ARRAY_ELEMENTS_TEXT(COALESCE(i.traits->'companyCodes', '[]'::jsonb)) AS code(value)
|
||||
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM active_tenant_refs AS refs
|
||||
WHERE refs.slug = LOWER(BTRIM(code.value))
|
||||
)
|
||||
)
|
||||
),
|
||||
updated_identities AS (
|
||||
UPDATE identities AS i
|
||||
SET traits = i.traits - 'tenant_id' - 'companyCode' - 'companyCodes',
|
||||
updated_at = NOW()
|
||||
FROM orphan_identities AS oi
|
||||
WHERE i.id = oi.id
|
||||
RETURNING
|
||||
i.id,
|
||||
oi.email,
|
||||
oi.tenant_id AS cleared_tenant_id,
|
||||
oi.company_code AS cleared_company_code,
|
||||
oi.company_codes AS cleared_company_codes
|
||||
)
|
||||
SELECT *
|
||||
FROM updated_identities
|
||||
ORDER BY email;
|
||||
|
||||
COMMIT;
|
||||
SQL
|
||||
67
baron-sso/scripts/clear_orphan_user_tenant_memberships.sql
Normal file
67
baron-sso/scripts/clear_orphan_user_tenant_memberships.sql
Normal file
@@ -0,0 +1,67 @@
|
||||
-- 삭제되었거나 존재하지 않는 tenant를 가리키는 사용자 소속정보를 정리한다.
|
||||
-- 실행 예:
|
||||
-- docker exec -i baron_postgres psql -U baron -d baron_sso < scripts/clear_orphan_user_tenant_memberships.sql
|
||||
|
||||
BEGIN;
|
||||
|
||||
WITH orphan_users AS (
|
||||
SELECT
|
||||
u.id,
|
||||
u.email,
|
||||
u.tenant_id,
|
||||
u.company_code,
|
||||
u.company_codes
|
||||
FROM users AS u
|
||||
WHERE u.deleted_at IS NULL
|
||||
AND (
|
||||
(
|
||||
u.tenant_id IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE t.id = u.tenant_id
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
OR (
|
||||
NULLIF(BTRIM(u.company_code), '') IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE LOWER(t.slug) = LOWER(BTRIM(u.company_code))
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM UNNEST(COALESCE(u.company_codes, ARRAY[]::text[])) AS code(value)
|
||||
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM tenants AS t
|
||||
WHERE LOWER(t.slug) = LOWER(BTRIM(code.value))
|
||||
AND t.deleted_at IS NULL
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
updated_users AS (
|
||||
UPDATE users AS u
|
||||
SET tenant_id = NULL,
|
||||
company_code = '',
|
||||
company_codes = NULL,
|
||||
updated_at = NOW()
|
||||
FROM orphan_users AS ou
|
||||
WHERE u.id = ou.id
|
||||
RETURNING
|
||||
u.id,
|
||||
u.email,
|
||||
ou.tenant_id AS cleared_tenant_id,
|
||||
ou.company_code AS cleared_company_code,
|
||||
ou.company_codes AS cleared_company_codes
|
||||
)
|
||||
SELECT *
|
||||
FROM updated_users
|
||||
ORDER BY email;
|
||||
|
||||
COMMIT;
|
||||
240
baron-sso/scripts/map_wasm_stack.py
Normal file
240
baron-sso/scripts/map_wasm_stack.py
Normal file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WASM 스택의 `wasm-function[IDX]:0xOFFSET`를 이름/소스 라인으로 매핑합니다.
|
||||
|
||||
사용 예시:
|
||||
python3 scripts/map_wasm_stack.py \
|
||||
--wasm userfront/build/web/main.dart.wasm \
|
||||
--sourcemap userfront/build/web/main.dart.wasm.map \
|
||||
--frame "19112:0x2cd913" --frame "765:0x10af0e"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import bisect
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
|
||||
BASE64_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
BASE64_MAP = {c: i for i, c in enumerate(BASE64_CHARS)}
|
||||
|
||||
|
||||
def read_u32_leb128(buf: bytes, i: int) -> Tuple[int, int]:
|
||||
value = 0
|
||||
shift = 0
|
||||
while True:
|
||||
b = buf[i]
|
||||
i += 1
|
||||
value |= (b & 0x7F) << shift
|
||||
if b < 0x80:
|
||||
return value, i
|
||||
shift += 7
|
||||
|
||||
|
||||
def decode_vlq_segment(segment: str) -> List[int]:
|
||||
out: List[int] = []
|
||||
i = 0
|
||||
while i < len(segment):
|
||||
shift = 0
|
||||
value = 0
|
||||
while True:
|
||||
d = BASE64_MAP[segment[i]]
|
||||
i += 1
|
||||
value |= (d & 0x1F) << shift
|
||||
shift += 5
|
||||
if (d & 0x20) == 0:
|
||||
break
|
||||
sign = value & 1
|
||||
value >>= 1
|
||||
out.append(-value if sign else value)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourcePoint:
|
||||
generated_col: int
|
||||
source_index: Optional[int]
|
||||
source_line: Optional[int]
|
||||
source_col: Optional[int]
|
||||
name_index: Optional[int]
|
||||
|
||||
|
||||
class WasmSourceMap:
|
||||
def __init__(self, sourcemap_path: Path):
|
||||
data = json.loads(sourcemap_path.read_text(encoding="utf-8"))
|
||||
self.sources: List[str] = data["sources"]
|
||||
self.names: List[str] = data.get("names", [])
|
||||
mappings: str = data["mappings"]
|
||||
# wasm sourcemap은 generated line 1개를 쓰는 형태라 ',' 단위로만 파싱합니다.
|
||||
segments = mappings.split(",")
|
||||
|
||||
points: List[SourcePoint] = []
|
||||
generated_col = 0
|
||||
source_index = 0
|
||||
source_line = 0
|
||||
source_col = 0
|
||||
name_index = 0
|
||||
|
||||
for seg in segments:
|
||||
if not seg:
|
||||
continue
|
||||
vals = decode_vlq_segment(seg)
|
||||
generated_col += vals[0]
|
||||
si: Optional[int] = None
|
||||
sl: Optional[int] = None
|
||||
sc: Optional[int] = None
|
||||
ni: Optional[int] = None
|
||||
if len(vals) >= 4:
|
||||
source_index += vals[1]
|
||||
source_line += vals[2]
|
||||
source_col += vals[3]
|
||||
si = source_index
|
||||
sl = source_line
|
||||
sc = source_col
|
||||
if len(vals) >= 5:
|
||||
name_index += vals[4]
|
||||
ni = name_index
|
||||
points.append(
|
||||
SourcePoint(
|
||||
generated_col=generated_col,
|
||||
source_index=si,
|
||||
source_line=sl,
|
||||
source_col=sc,
|
||||
name_index=ni,
|
||||
)
|
||||
)
|
||||
self.points = points
|
||||
self.columns = [p.generated_col for p in points]
|
||||
|
||||
def lookup(self, offset: int) -> Optional[SourcePoint]:
|
||||
idx = bisect.bisect_right(self.columns, offset) - 1
|
||||
if idx < 0:
|
||||
return None
|
||||
return self.points[idx]
|
||||
|
||||
def source_name(self, index: Optional[int]) -> Optional[str]:
|
||||
if index is None or index < 0 or index >= len(self.sources):
|
||||
return None
|
||||
return self.sources[index]
|
||||
|
||||
def symbol_name(self, index: Optional[int]) -> Optional[str]:
|
||||
if index is None or index < 0 or index >= len(self.names):
|
||||
return None
|
||||
return self.names[index]
|
||||
|
||||
|
||||
def parse_wasm_function_names(wasm_path: Path) -> Dict[int, str]:
|
||||
b = wasm_path.read_bytes()
|
||||
if b[:4] != b"\x00asm":
|
||||
raise ValueError(f"Not a wasm binary: {wasm_path}")
|
||||
|
||||
function_names: Dict[int, str] = {}
|
||||
i = 8 # magic + version
|
||||
|
||||
while i < len(b):
|
||||
section_id = b[i]
|
||||
i += 1
|
||||
section_size, i = read_u32_leb128(b, i)
|
||||
section_start = i
|
||||
section_end = i + section_size
|
||||
|
||||
if section_id == 0: # custom section
|
||||
name_len, j = read_u32_leb128(b, i)
|
||||
custom_name = b[j : j + name_len].decode("utf-8", errors="replace")
|
||||
payload_start = j + name_len
|
||||
if custom_name == "name":
|
||||
k = payload_start
|
||||
while k < section_end:
|
||||
subsection_id = b[k]
|
||||
k += 1
|
||||
subsection_size, k = read_u32_leb128(b, k)
|
||||
subsection_end = k + subsection_size
|
||||
if subsection_id == 1: # function names
|
||||
count, k = read_u32_leb128(b, k)
|
||||
for _ in range(count):
|
||||
fn_idx, k = read_u32_leb128(b, k)
|
||||
nlen, k = read_u32_leb128(b, k)
|
||||
name = b[k : k + nlen].decode("utf-8", errors="replace")
|
||||
k += nlen
|
||||
function_names[fn_idx] = name
|
||||
else:
|
||||
k = subsection_end
|
||||
|
||||
i = section_end
|
||||
return function_names
|
||||
|
||||
|
||||
def parse_frame(raw: str) -> Tuple[int, int]:
|
||||
m = re.match(r"^\s*(\d+)\s*:\s*(0x[0-9a-fA-F]+)\s*$", raw)
|
||||
if not m:
|
||||
raise ValueError(f"Invalid --frame format: {raw!r} (expected IDX:0xOFFSET)")
|
||||
return int(m.group(1)), int(m.group(2), 16)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
p = argparse.ArgumentParser(description="Map wasm stack frames to source locations")
|
||||
p.add_argument("--wasm", required=True, type=Path, help="WASM binary path")
|
||||
p.add_argument("--sourcemap", required=True, type=Path, help="WASM sourcemap path")
|
||||
p.add_argument(
|
||||
"--frame",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Frame in IDX:0xOFFSET format (repeatable)",
|
||||
)
|
||||
p.add_argument(
|
||||
"--offset",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Offset only (hex), function index unknown",
|
||||
)
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
source_map = WasmSourceMap(args.sourcemap)
|
||||
function_names = parse_wasm_function_names(args.wasm)
|
||||
|
||||
targets: List[Tuple[Optional[int], int]] = []
|
||||
for f in args.frame:
|
||||
idx, off = parse_frame(f)
|
||||
targets.append((idx, off))
|
||||
for off in args.offset:
|
||||
targets.append((None, int(off, 16)))
|
||||
|
||||
if not targets:
|
||||
raise SystemExit("No targets. Provide --frame or --offset.")
|
||||
|
||||
for fn_idx, off in targets:
|
||||
point = source_map.lookup(off)
|
||||
fn_name = function_names.get(fn_idx) if fn_idx is not None else None
|
||||
mapped_col = point.generated_col if point else None
|
||||
src = source_map.source_name(point.source_index) if point else None
|
||||
src_line = (point.source_line + 1) if point and point.source_line is not None else None
|
||||
src_col = (point.source_col + 1) if point and point.source_col is not None else None
|
||||
symbol = source_map.symbol_name(point.name_index) if point else None
|
||||
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"function_index": fn_idx,
|
||||
"function_name": fn_name,
|
||||
"offset_hex": hex(off),
|
||||
"mapped_generated_col_hex": hex(mapped_col) if mapped_col is not None else None,
|
||||
"source": src,
|
||||
"source_line": src_line,
|
||||
"source_column": src_col,
|
||||
"symbol": symbol,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
60
baron-sso/scripts/playwrightHostDeps.cjs
Normal file
60
baron-sso/scripts/playwrightHostDeps.cjs
Normal file
@@ -0,0 +1,60 @@
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const webkitHostLibraries = [
|
||||
"libgtk-4.so.1",
|
||||
"libgraphene-1.0.so.0",
|
||||
"libxslt.so.1",
|
||||
"libevent-2.1.so.7",
|
||||
"libopus.so.0",
|
||||
"libgstallocators-1.0.so.0",
|
||||
"libgstapp-1.0.so.0",
|
||||
"libgstpbutils-1.0.so.0",
|
||||
"libgstaudio-1.0.so.0",
|
||||
"libgsttag-1.0.so.0",
|
||||
"libgstvideo-1.0.so.0",
|
||||
"libgstgl-1.0.so.0",
|
||||
"libgstcodecparsers-1.0.so.0",
|
||||
"libgstfft-1.0.so.0",
|
||||
"libflite.so.1",
|
||||
"libwebpdemux.so.2",
|
||||
"libavif.so.16",
|
||||
"libharfbuzz-icu.so.0",
|
||||
"libwebpmux.so.3",
|
||||
"libwayland-server.so.0",
|
||||
"libmanette-0.2.so.0",
|
||||
"libenchant-2.so.2",
|
||||
"libhyphen.so.0",
|
||||
"libsecret-1.so.0",
|
||||
"libwoff2dec.so.1.0.2",
|
||||
"libx264.so",
|
||||
];
|
||||
|
||||
function hasWebKitHostDependencies() {
|
||||
if (process.platform !== "linux") {
|
||||
return true;
|
||||
}
|
||||
|
||||
let output = "";
|
||||
try {
|
||||
output = execFileSync("ldconfig", ["-p"], { encoding: "utf8" });
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
return webkitHostLibraries.every((library) => output.includes(library));
|
||||
}
|
||||
|
||||
function shouldIncludeWebKit() {
|
||||
if (process.env.PLAYWRIGHT_FORCE_WEBKIT === "1") {
|
||||
return true;
|
||||
}
|
||||
if (process.env.PLAYWRIGHT_SKIP_WEBKIT === "1") {
|
||||
return false;
|
||||
}
|
||||
return hasWebKitHostDependencies();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hasWebKitHostDependencies,
|
||||
shouldIncludeWebKit,
|
||||
};
|
||||
24
baron-sso/scripts/playwrightPackageVersion.cjs
Normal file
24
baron-sso/scripts/playwrightPackageVersion.cjs
Normal file
@@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const packageDir = process.argv[2];
|
||||
|
||||
if (!packageDir) {
|
||||
console.error("Usage: node scripts/playwrightPackageVersion.cjs <package-dir>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const packageJsonPath = path.resolve(process.cwd(), packageDir, "package.json");
|
||||
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
||||
const version =
|
||||
packageJson.devDependencies?.["@playwright/test"] ??
|
||||
packageJson.dependencies?.["@playwright/test"];
|
||||
|
||||
if (!version) {
|
||||
console.error(`@playwright/test version not found in ${packageJsonPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`version=${version.replace(/^[^0-9]*/, "")}`);
|
||||
228
baron-sso/scripts/render_ory_config.sh
Normal file
228
baron-sso/scripts/render_ory_config.sh
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUTPUT_DIR="${ORY_CONFIG_OUTPUT_DIR:-$ROOT_DIR/config/.generated/ory}"
|
||||
TEMPLATE_ROOT="${ORY_CONFIG_TEMPLATE_ROOT:-$ROOT_DIR/docker/ory}"
|
||||
|
||||
load_env_file() {
|
||||
local env_file="$1"
|
||||
if [[ -f "$env_file" ]]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$env_file"
|
||||
set +a
|
||||
fi
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "[ory-config] ERROR: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
render_template() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
perl -pe '
|
||||
s/\$\{([A-Za-z_][A-Za-z0-9_]*)(:-([^}]*))?\}/
|
||||
exists $ENV{$1} ? $ENV{$1} : defined $3 ? $3 : die "missing env var: $1\n"
|
||||
/gex
|
||||
' "$src" > "$dst"
|
||||
}
|
||||
|
||||
copy_if_exists() {
|
||||
local src="$1"
|
||||
local dst="$2"
|
||||
if [[ -e "$src" ]]; then
|
||||
mkdir -p "$(dirname "$dst")"
|
||||
cp -a "$src" "$dst"
|
||||
fi
|
||||
}
|
||||
|
||||
json_array_to_lines() {
|
||||
local json="$1"
|
||||
local newline=$'\n'
|
||||
json="${json//$'\n'/}"
|
||||
json="${json#\[}"
|
||||
json="${json%\]}"
|
||||
json="${json//\",\"/$newline}"
|
||||
json="${json//\"/}"
|
||||
json="${json//,/$newline}"
|
||||
printf '%s\n' "$json" | sed '/^[[:space:]]*$/d'
|
||||
}
|
||||
|
||||
append_unique_url() {
|
||||
local candidate="${1:-}"
|
||||
[[ -n "$candidate" ]] || return 0
|
||||
local existing
|
||||
for existing in "${KRATOS_ALLOWED_RETURN_URLS[@]}"; do
|
||||
[[ "$existing" == "$candidate" ]] && return 0
|
||||
done
|
||||
KRATOS_ALLOWED_RETURN_URLS+=("$candidate")
|
||||
}
|
||||
|
||||
url_host() {
|
||||
local url="${1:-}"
|
||||
[[ -n "$url" ]] || return 0
|
||||
|
||||
local without_scheme="$url"
|
||||
if [[ "$without_scheme" == *"://"* ]]; then
|
||||
without_scheme="${without_scheme#*://}"
|
||||
fi
|
||||
without_scheme="${without_scheme%%/*}"
|
||||
without_scheme="${without_scheme%%\?*}"
|
||||
without_scheme="${without_scheme%%#*}"
|
||||
|
||||
if [[ "$without_scheme" == \[*\]* ]]; then
|
||||
without_scheme="${without_scheme#[}"
|
||||
without_scheme="${without_scheme%%]*}"
|
||||
elif [[ "$without_scheme" == *:* ]]; then
|
||||
without_scheme="${without_scheme%%:*}"
|
||||
fi
|
||||
|
||||
printf '%s' "$without_scheme"
|
||||
}
|
||||
|
||||
resolve_kratos_session_cookie_domain() {
|
||||
if [[ -n "${KRATOS_SESSION_COOKIE_DOMAIN:-}" ]]; then
|
||||
export KRATOS_SESSION_COOKIE_DOMAIN
|
||||
return 0
|
||||
fi
|
||||
|
||||
local public_host
|
||||
public_host="$(url_host "${KRATOS_BROWSER_URL:-}")"
|
||||
if [[ -z "$public_host" ]]; then
|
||||
public_host="$(url_host "${KRATOS_UI_URL:-}")"
|
||||
fi
|
||||
|
||||
case "$public_host" in
|
||||
localhost|127.0.0.1|0.0.0.0|*.localhost)
|
||||
KRATOS_SESSION_COOKIE_DOMAIN="localhost"
|
||||
;;
|
||||
*.hmac.kr|hmac.kr)
|
||||
KRATOS_SESSION_COOKIE_DOMAIN="hmac.kr"
|
||||
;;
|
||||
"")
|
||||
KRATOS_SESSION_COOKIE_DOMAIN="localhost"
|
||||
;;
|
||||
*)
|
||||
KRATOS_SESSION_COOKIE_DOMAIN="$public_host"
|
||||
;;
|
||||
esac
|
||||
|
||||
export KRATOS_SESSION_COOKIE_DOMAIN
|
||||
}
|
||||
|
||||
build_kratos_allowed_return_urls_yaml() {
|
||||
KRATOS_ALLOWED_RETURN_URLS=()
|
||||
if [[ -n "${KRATOS_ALLOWED_RETURN_URLS_JSON:-}" ]]; then
|
||||
while IFS= read -r allowed_url; do
|
||||
append_unique_url "$allowed_url"
|
||||
done < <(json_array_to_lines "$KRATOS_ALLOWED_RETURN_URLS_JSON")
|
||||
fi
|
||||
|
||||
if [[ ${#KRATOS_ALLOWED_RETURN_URLS[@]} -eq 0 ]]; then
|
||||
local kratos_ui="${KRATOS_UI_URL:-http://localhost:5000}"
|
||||
local userfront="${USERFRONT_URL:-http://localhost:5000}"
|
||||
local adminfront="${ADMINFRONT_URL:-http://localhost:5173}"
|
||||
local devfront="${DEVFRONT_URL:-http://localhost:5174}"
|
||||
local orgfront="${ORGFRONT_URL:-http://localhost:5175}"
|
||||
|
||||
append_unique_url "$kratos_ui"
|
||||
append_unique_url "$kratos_ui/"
|
||||
append_unique_url "$userfront"
|
||||
append_unique_url "$userfront/"
|
||||
append_unique_url "$userfront/ko"
|
||||
append_unique_url "$userfront/ko/"
|
||||
append_unique_url "$userfront/en"
|
||||
append_unique_url "$userfront/en/"
|
||||
append_unique_url "$userfront/auth/callback"
|
||||
append_unique_url "$userfront/ko/auth/callback"
|
||||
append_unique_url "$userfront/en/auth/callback"
|
||||
append_unique_url "$adminfront/auth/callback"
|
||||
append_unique_url "$devfront/auth/callback"
|
||||
append_unique_url "$orgfront/auth/callback"
|
||||
fi
|
||||
|
||||
if [[ -n "${KRATOS_ALLOWED_RETURN_URLS_EXTRA:-}" ]]; then
|
||||
IFS=',' read -r -a extra_urls <<<"$KRATOS_ALLOWED_RETURN_URLS_EXTRA"
|
||||
local extra_url
|
||||
for extra_url in "${extra_urls[@]}"; do
|
||||
extra_url="$(printf '%s' "$extra_url" | xargs)"
|
||||
append_unique_url "$extra_url"
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ ${#KRATOS_ALLOWED_RETURN_URLS[@]} -eq 0 ]]; then
|
||||
fail "Kratos allowed_return_urls is empty"
|
||||
fi
|
||||
|
||||
KRATOS_ALLOWED_RETURN_URLS_YAML="$(
|
||||
printf '%s\n' "${KRATOS_ALLOWED_RETURN_URLS[@]}" | sed 's/^/ - /'
|
||||
)"
|
||||
export KRATOS_ALLOWED_RETURN_URLS_YAML
|
||||
}
|
||||
|
||||
if [[ -n "${ORY_CONFIG_ENV_FILES:-}" ]]; then
|
||||
IFS=':' read -r -a env_files <<<"$ORY_CONFIG_ENV_FILES"
|
||||
for env_file in "${env_files[@]}"; do
|
||||
load_env_file "$env_file"
|
||||
done
|
||||
else
|
||||
load_env_file "$ROOT_DIR/.env"
|
||||
load_env_file "$ROOT_DIR/config/.generated/auth-config.env"
|
||||
fi
|
||||
|
||||
ORY_POSTGRES_USER="${ORY_POSTGRES_USER:-ory}"
|
||||
ORY_POSTGRES_PASSWORD="${ORY_POSTGRES_PASSWORD:-secret}"
|
||||
KRATOS_DB="${KRATOS_DB:-ory_kratos}"
|
||||
HYDRA_DB="${HYDRA_DB:-ory_hydra}"
|
||||
KETO_DB="${KETO_DB:-ory_keto}"
|
||||
KRATOS_DSN="${KRATOS_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KRATOS_DB}?sslmode=disable&max_conns=20}"
|
||||
HYDRA_DSN="${HYDRA_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${HYDRA_DB}?sslmode=disable&max_conns=20}"
|
||||
KETO_DSN="${KETO_DSN:-postgres://${ORY_POSTGRES_USER}:${ORY_POSTGRES_PASSWORD}@postgres_ory:5432/${KETO_DB}?sslmode=disable&max_conns=20}"
|
||||
HYDRA_SYSTEM_SECRET="${HYDRA_SYSTEM_SECRET:-${SECRETS_SYSTEM:-${ORY_POSTGRES_PASSWORD}}}"
|
||||
OATHKEEPER_INTROSPECT_CLIENT_ID="${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}"
|
||||
OATHKEEPER_INTROSPECT_CLIENT_SECRET="${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}"
|
||||
|
||||
export KRATOS_DSN HYDRA_DSN KETO_DSN HYDRA_SYSTEM_SECRET
|
||||
export OATHKEEPER_INTROSPECT_CLIENT_ID OATHKEEPER_INTROSPECT_CLIENT_SECRET
|
||||
|
||||
resolve_kratos_session_cookie_domain
|
||||
build_kratos_allowed_return_urls_yaml
|
||||
|
||||
mkdir -p "$OUTPUT_DIR/kratos" "$OUTPUT_DIR/hydra" "$OUTPUT_DIR/keto" "$OUTPUT_DIR/oathkeeper"
|
||||
|
||||
render_template "$TEMPLATE_ROOT/kratos/kratos.yml.template" "$OUTPUT_DIR/kratos/kratos.yml"
|
||||
copy_if_exists "$TEMPLATE_ROOT/kratos/identity.schema.json" "$OUTPUT_DIR/kratos/identity.schema.json"
|
||||
copy_if_exists "$TEMPLATE_ROOT/kratos/courier-http.jsonnet" "$OUTPUT_DIR/kratos/courier-http.jsonnet"
|
||||
if [[ -d "$TEMPLATE_ROOT/kratos/courier-templates" ]]; then
|
||||
mkdir -p "$OUTPUT_DIR/kratos"
|
||||
rm -rf "$OUTPUT_DIR/kratos/courier-templates"
|
||||
cp -a "$TEMPLATE_ROOT/kratos/courier-templates" "$OUTPUT_DIR/kratos/courier-templates"
|
||||
fi
|
||||
|
||||
render_template "$TEMPLATE_ROOT/hydra/hydra.yml.template" "$OUTPUT_DIR/hydra/hydra.yml"
|
||||
|
||||
render_template "$TEMPLATE_ROOT/keto/keto.yml.template" "$OUTPUT_DIR/keto/keto.yml"
|
||||
copy_if_exists "$TEMPLATE_ROOT/keto/namespaces.ts" "$OUTPUT_DIR/keto/namespaces.ts"
|
||||
copy_if_exists "$TEMPLATE_ROOT/keto/namespaces.yml" "$OUTPUT_DIR/keto/namespaces.yml"
|
||||
|
||||
render_template "$TEMPLATE_ROOT/oathkeeper/oathkeeper.yml.template" "$OUTPUT_DIR/oathkeeper/oathkeeper.yml"
|
||||
copy_if_exists "$TEMPLATE_ROOT/oathkeeper/entrypoint.sh" "$OUTPUT_DIR/oathkeeper/entrypoint.sh"
|
||||
chmod +x "$OUTPUT_DIR/oathkeeper/entrypoint.sh"
|
||||
find "$OUTPUT_DIR/oathkeeper" -maxdepth 1 -type f -name 'rules*.json' -delete
|
||||
for rules_file in "$TEMPLATE_ROOT"/oathkeeper/rules*.json; do
|
||||
[[ -e "$rules_file" ]] || continue
|
||||
copy_if_exists "$rules_file" "$OUTPUT_DIR/oathkeeper/$(basename "$rules_file")"
|
||||
done
|
||||
|
||||
if find "$OUTPUT_DIR" -type f \( -name '*.yml' -o -name '*.yaml' -o -name '*.json' -o -name '*.toml' \) -print0 | xargs -0 grep -n '\${' >/tmp/ory-render-unresolved.$$ 2>/dev/null; then
|
||||
cat /tmp/ory-render-unresolved.$$ >&2
|
||||
rm -f /tmp/ory-render-unresolved.$$
|
||||
fail "rendered Ory config contains unresolved placeholders"
|
||||
fi
|
||||
rm -f /tmp/ory-render-unresolved.$$
|
||||
|
||||
echo "[ory-config] wrote: $OUTPUT_DIR"
|
||||
330
baron-sso/scripts/run_adminfront_ci_tests.sh
Normal file
330
baron-sso/scripts/run_adminfront_ci_tests.sh
Normal file
@@ -0,0 +1,330 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
job_name="${1:-adminfront-tests}"
|
||||
repo_root="$(pwd)"
|
||||
tmp_dir=""
|
||||
|
||||
cleanup() {
|
||||
if [ -n "${tmp_dir:-}" ] && [ -d "$tmp_dir" ]; then
|
||||
rm -rf "$tmp_dir" || true
|
||||
fi
|
||||
}
|
||||
|
||||
trap "cleanup; exit" INT TERM
|
||||
trap "cleanup" EXIT
|
||||
|
||||
mkdir -p reports
|
||||
|
||||
tmp_dir="$(mktemp -d /tmp/baron-sso-adminfront-tests.XXXXXX)"
|
||||
pnpm_store_dir="$tmp_dir/pnpm-store"
|
||||
# [Fix] Use a relative path for the build output directory
|
||||
export ADMINFRONT_BUILD_OUT_DIR="dist"
|
||||
|
||||
seed_dir=""
|
||||
for candidate in \
|
||||
/tmp/baron-sso-adminfront-tests.FRPGmL \
|
||||
/tmp/baron-sso-adminfront-tests.mumSD6 \
|
||||
/tmp/baron-sso-adminfront-tests.pwAMAt; do
|
||||
if [ -d "$candidate/adminfront/node_modules" ] && \
|
||||
[ -d "$candidate/common/node_modules" ]; then
|
||||
seed_dir="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$seed_dir" ]; then
|
||||
for candidate in /tmp/baron-sso-adminfront-tests.*; do
|
||||
if [ "$candidate" != "$tmp_dir" ] && \
|
||||
[ -d "$candidate/adminfront/node_modules" ] && \
|
||||
[ -d "$candidate/common/node_modules" ]; then
|
||||
seed_dir="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
reuse_seed_node_modules=0
|
||||
mkdir -p "$tmp_dir/scripts"
|
||||
cp "$repo_root/scripts/playwrightHostDeps.cjs" "$tmp_dir/scripts/"
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
rsync -rlptD --delete \
|
||||
--exclude 'node_modules' \
|
||||
--exclude 'playwright-report' \
|
||||
--exclude 'test-results' \
|
||||
"$repo_root/adminfront/" "$tmp_dir/adminfront/"
|
||||
rsync -rlptD --delete \
|
||||
--exclude 'node_modules' \
|
||||
"$repo_root/common/" "$tmp_dir/common/"
|
||||
rm -rf "$tmp_dir/common/node_modules"
|
||||
else
|
||||
cp -R "$repo_root/adminfront" "$tmp_dir/adminfront"
|
||||
cp -R "$repo_root/common" "$tmp_dir/common"
|
||||
rm -rf "$tmp_dir/adminfront/node_modules" \
|
||||
"$tmp_dir/common/node_modules" \
|
||||
"$tmp_dir/adminfront/playwright-report" \
|
||||
"$tmp_dir/adminfront/test-results"
|
||||
fi
|
||||
|
||||
if [ -n "$seed_dir" ] && [ "$seed_dir" != "$tmp_dir" ] && \
|
||||
[ -d "$seed_dir/adminfront/node_modules" ] && \
|
||||
[ -d "$seed_dir/common/node_modules" ]; then
|
||||
cp -a "$seed_dir/adminfront/node_modules" "$tmp_dir/adminfront/"
|
||||
cp -a "$seed_dir/common/node_modules" "$tmp_dir/common/"
|
||||
reuse_seed_node_modules=1
|
||||
fi
|
||||
|
||||
if [ ! -d "$tmp_dir/adminfront/node_modules" ] || \
|
||||
[ ! -d "$tmp_dir/common/node_modules" ]; then
|
||||
rm -rf "$tmp_dir/adminfront/playwright-report" \
|
||||
"$tmp_dir/adminfront/test-results"
|
||||
fi
|
||||
|
||||
is_port_available() {
|
||||
local port="$1"
|
||||
node -e '
|
||||
const net = require("net");
|
||||
const port = Number(process.argv[1]);
|
||||
const server = net.createServer();
|
||||
server.once("error", () => process.exit(1));
|
||||
server.once("listening", () => server.close(() => process.exit(0)));
|
||||
server.listen(port, "127.0.0.1");
|
||||
' "$port"
|
||||
}
|
||||
|
||||
find_available_port() {
|
||||
node -e '
|
||||
const net = require("net");
|
||||
const server = net.createServer();
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
process.stdout.write(String(address.port));
|
||||
server.close();
|
||||
});
|
||||
'
|
||||
}
|
||||
|
||||
run_with_retry() {
|
||||
local max_attempts="$1"
|
||||
shift
|
||||
|
||||
local attempt=1
|
||||
local exit_code=0
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
if "$@"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
exit_code=$?
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
return "$exit_code"
|
||||
fi
|
||||
|
||||
echo "==> command failed (attempt $attempt/$max_attempts): $*"
|
||||
echo "==> retrying in 10 seconds..."
|
||||
sleep 10
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
return "$exit_code"
|
||||
}
|
||||
|
||||
playwright_install_cmd=(pnpm exec playwright install)
|
||||
playwright_install_desc="pnpm exec playwright install"
|
||||
playwright_project_args=()
|
||||
|
||||
has_webkit_host_dependencies() {
|
||||
if [ "$(uname -s)" != "Linux" ]; then
|
||||
return 0
|
||||
fi
|
||||
if ! command -v ldconfig >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
local missing=0
|
||||
local lib
|
||||
for lib in \
|
||||
libgtk-4.so.1 \
|
||||
libgraphene-1.0.so.0 \
|
||||
libxslt.so.1 \
|
||||
libevent-2.1.so.7 \
|
||||
libopus.so.0 \
|
||||
libgstallocators-1.0.so.0 \
|
||||
libgstapp-1.0.so.0 \
|
||||
libgstpbutils-1.0.so.0 \
|
||||
libgstaudio-1.0.so.0 \
|
||||
libgsttag-1.0.so.0 \
|
||||
libgstvideo-1.0.so.0 \
|
||||
libgstgl-1.0.so.0 \
|
||||
libgstcodecparsers-1.0.so.0 \
|
||||
libgstfft-1.0.so.0 \
|
||||
libflite.so.1 \
|
||||
libwebpdemux.so.2 \
|
||||
libavif.so.16 \
|
||||
libharfbuzz-icu.so.0 \
|
||||
libwebpmux.so.3 \
|
||||
libwayland-server.so.0 \
|
||||
libmanette-0.2.so.0 \
|
||||
libenchant-2.so.2 \
|
||||
libhyphen.so.0 \
|
||||
libsecret-1.so.0 \
|
||||
libwoff2dec.so.1.0.2 \
|
||||
libx264.so; do
|
||||
if ! ldconfig -p 2>/dev/null | grep -Fq "$lib"; then
|
||||
missing=1
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
[ "$missing" -eq 0 ]
|
||||
}
|
||||
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
playwright_install_cmd=(pnpm exec playwright install --with-deps)
|
||||
playwright_install_desc="pnpm exec playwright install --with-deps"
|
||||
elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then
|
||||
playwright_install_cmd=(pnpm exec playwright install --with-deps)
|
||||
playwright_install_desc="pnpm exec playwright install --with-deps"
|
||||
elif ! has_webkit_host_dependencies; then
|
||||
playwright_install_cmd=(pnpm exec playwright install chromium firefox)
|
||||
playwright_install_desc="pnpm exec playwright install chromium firefox"
|
||||
playwright_project_args=(--project=chromium --project=firefox)
|
||||
{
|
||||
echo "# Adminfront WebKit Skipped"
|
||||
echo
|
||||
echo "- Reason: WebKit host dependencies are not installed and this user cannot run passwordless sudo."
|
||||
echo "- Action: Running Chromium and Firefox projects only."
|
||||
echo "- To enable WebKit locally: run \`cd adminfront && pnpm exec playwright install-deps webkit\` with sudo privileges."
|
||||
} > reports/adminfront-webkit-skipped.md
|
||||
fi
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$tmp_dir/common"
|
||||
echo "packages: ['.', '../adminfront']" > pnpm-workspace.yaml
|
||||
|
||||
if [ "$reuse_seed_node_modules" -eq 0 ]; then
|
||||
corepack enable
|
||||
corepack prepare pnpm@10.5.2 --activate
|
||||
run_with_retry 3 env CI=true pnpm install --no-frozen-lockfile --shamefully-hoist --store-dir "$pnpm_store_dir"
|
||||
fi
|
||||
|
||||
cd "$tmp_dir/adminfront"
|
||||
run_with_retry 3 env CI=true pnpm install --no-frozen-lockfile --shamefully-hoist --store-dir "$pnpm_store_dir"
|
||||
) 2>&1 | tee reports/adminfront-install.log
|
||||
install_exit_code=${STATUS:-$?}
|
||||
set -e
|
||||
|
||||
if [ "$install_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Adminfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`${job_name}\`"
|
||||
echo "- Reason: \`Dependency install failed\`"
|
||||
echo "- Exit Code: \`$install_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd common && echo \"packages: ['.', '../adminfront']\" > pnpm-workspace.yaml && env CI=true pnpm install --no-frozen-lockfile --shamefully-hoist\`"
|
||||
echo
|
||||
echo "## Install Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/adminfront-install.log
|
||||
echo '```'
|
||||
} > reports/adminfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# [Fix] Explicitly build to 'dist' and ensure it's available for 'vite preview'.
|
||||
(
|
||||
cd "$tmp_dir/adminfront"
|
||||
env CI=true ADMINFRONT_BUILD_OUT_DIR="dist" pnpm run build
|
||||
) 2>&1 | tee reports/adminfront-build.log
|
||||
build_exit_code=${PIPESTATUS[0]}
|
||||
if [ "$build_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Adminfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`${job_name}\`"
|
||||
echo "- Reason: \`Build failed\`"
|
||||
echo "- Exit Code: \`$build_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd adminfront && env CI=true pnpm run build\`"
|
||||
echo
|
||||
echo "## Build Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/adminfront-build.log
|
||||
echo '```'
|
||||
} > reports/adminfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
(
|
||||
cd "$tmp_dir/adminfront"
|
||||
"${playwright_install_cmd[@]}"
|
||||
) 2>&1 | tee reports/adminfront-provision.log
|
||||
provision_exit_code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
if [ "$provision_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Adminfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`${job_name}\`"
|
||||
echo "- Reason: \`Browser provisioning failed\`"
|
||||
echo "- Exit Code: \`$provision_exit_code\`"
|
||||
echo
|
||||
echo "## Command"
|
||||
echo "\`cd adminfront && ${playwright_install_desc}\`"
|
||||
echo "## Provision Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/adminfront-provision.log
|
||||
echo '```'
|
||||
} > reports/adminfront-test-failure-report.md
|
||||
exit 1
|
||||
fi
|
||||
|
||||
set +e
|
||||
port="${PORT:-5180}"
|
||||
if ! is_port_available "$port"; then
|
||||
fallback_port="$(find_available_port)"
|
||||
echo "==> requested PORT=$port is already in use; switching to PORT=$fallback_port"
|
||||
port="$fallback_port"
|
||||
fi
|
||||
echo "==> adminfront using PORT=$port"
|
||||
(
|
||||
cd "$tmp_dir/adminfront"
|
||||
CI=true PORT="$port" PLAYWRIGHT_WORKERS="${PLAYWRIGHT_WORKERS:-1}" \
|
||||
pnpm exec playwright test --max-failures=1 "${playwright_project_args[@]}"
|
||||
) 2>&1 | tee reports/adminfront-test.log
|
||||
test_exit_code=${PIPESTATUS[0]}
|
||||
set -e
|
||||
|
||||
[ -d "$tmp_dir/adminfront/playwright-report" ] && rm -rf reports/adminfront-playwright-report && cp -R "$tmp_dir/adminfront/playwright-report" reports/adminfront-playwright-report || true
|
||||
[ -d "$tmp_dir/adminfront/test-results" ] && rm -rf reports/adminfront-test-results && cp -R "$tmp_dir/adminfront/test-results" reports/adminfront-test-results || true
|
||||
|
||||
if [ "$test_exit_code" -ne 0 ]; then
|
||||
{
|
||||
echo "# Adminfront Test Failure Report"
|
||||
echo
|
||||
echo "- Workflow: \`${GITHUB_WORKFLOW:-Code Check}\`"
|
||||
echo "- Job: \`${job_name}\`"
|
||||
echo "- Exit Code: \`$test_exit_code\`"
|
||||
echo
|
||||
echo "## Commands"
|
||||
echo "1. \`cd common && echo \"packages: ['.', '../adminfront']\" > pnpm-workspace.yaml && env CI=true pnpm install --no-frozen-lockfile --shamefully-hoist\`"
|
||||
echo "2. \`cd adminfront && env CI=true pnpm run build\`"
|
||||
echo "3. \`${playwright_install_desc}\`"
|
||||
echo "4. \`pnpm exec playwright test\`"
|
||||
echo
|
||||
echo "## Log Tail (last 200 lines)"
|
||||
echo '```text'
|
||||
tail -n 200 reports/adminfront-test.log
|
||||
echo '```'
|
||||
} > reports/adminfront-test-failure-report.md
|
||||
fi
|
||||
|
||||
exit "$test_exit_code"
|
||||
8
baron-sso/scripts/sanitize_baron_user_metadata.sql
Normal file
8
baron-sso/scripts/sanitize_baron_user_metadata.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- Baron user metadata staging normalization.
|
||||
-- Idempotently removes legacy classification flags that are no longer SoT.
|
||||
|
||||
update users
|
||||
set metadata = metadata - 'hanmacFamily' - 'userType',
|
||||
updated_at = now()
|
||||
where metadata ? 'hanmacFamily'
|
||||
or metadata ? 'userType';
|
||||
155
baron-sso/scripts/serve_frontend_prod.mjs
Normal file
155
baron-sso/scripts/serve_frontend_prod.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
|
||||
const distDir = resolve(process.env.FRONTEND_DIST_DIR ?? "/app/dist");
|
||||
const host = process.env.HOST ?? "0.0.0.0";
|
||||
const port = Number(process.env.PORT ?? 5173);
|
||||
const backendTarget = new URL(
|
||||
process.env.API_PROXY_TARGET || "http://localhost:3000",
|
||||
);
|
||||
|
||||
const contentTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".js": "application/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".map": "application/json; charset=utf-8",
|
||||
".mjs": "application/javascript; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".txt": "text/plain; charset=utf-8",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
function getContentType(filePath) {
|
||||
return (
|
||||
contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"
|
||||
);
|
||||
}
|
||||
|
||||
function sendJson(res, statusCode, body) {
|
||||
res.writeHead(statusCode, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
res.end(JSON.stringify(body));
|
||||
}
|
||||
|
||||
function toSafePath(pathname) {
|
||||
const decoded = decodeURIComponent(pathname);
|
||||
const relative = decoded.replace(/^\/+/, "");
|
||||
const safe = normalize(relative).replace(/^(\.\.(?:[\\/]|$))+/, "");
|
||||
return join(distDir, safe);
|
||||
}
|
||||
|
||||
async function tryReadFile(filePath) {
|
||||
try {
|
||||
return await readFile(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function proxyToBackend(req, res, pathname, search) {
|
||||
const target = new URL(pathname + search, backendTarget);
|
||||
const headers = new Headers();
|
||||
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (!value) continue;
|
||||
if (key === "host" || key === "content-length" || key === "connection") {
|
||||
continue;
|
||||
}
|
||||
headers.set(key, Array.isArray(value) ? value.join(", ") : value);
|
||||
}
|
||||
|
||||
const hasBody = !["GET", "HEAD"].includes(req.method ?? "GET");
|
||||
const response = await fetch(target, {
|
||||
method: req.method,
|
||||
headers,
|
||||
body: hasBody ? req : undefined,
|
||||
duplex: hasBody ? "half" : undefined,
|
||||
});
|
||||
|
||||
const responseHeaders = new Headers(response.headers);
|
||||
responseHeaders.delete("content-length");
|
||||
responseHeaders.delete("transfer-encoding");
|
||||
responseHeaders.delete("connection");
|
||||
|
||||
res.writeHead(response.status, Object.fromEntries(responseHeaders.entries()));
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
res.end(Buffer.from(arrayBuffer));
|
||||
}
|
||||
|
||||
async function serveStatic(req, res, pathname) {
|
||||
const indexPath = join(distDir, "index.html");
|
||||
const filePath = toSafePath(pathname);
|
||||
|
||||
let resolvedPath = filePath;
|
||||
try {
|
||||
const fileStat = await stat(resolvedPath);
|
||||
if (fileStat.isDirectory()) {
|
||||
resolvedPath = join(resolvedPath, "index.html");
|
||||
}
|
||||
} catch {
|
||||
resolvedPath = indexPath;
|
||||
}
|
||||
|
||||
let body = await tryReadFile(resolvedPath);
|
||||
if (!body) {
|
||||
body = await tryReadFile(indexPath);
|
||||
resolvedPath = indexPath;
|
||||
}
|
||||
|
||||
if (!body) {
|
||||
sendJson(res, 500, { error: "dist_not_found" });
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": getContentType(resolvedPath),
|
||||
"Cache-Control": resolvedPath.endsWith("index.html")
|
||||
? "no-cache"
|
||||
: "public, max-age=31536000, immutable",
|
||||
});
|
||||
|
||||
if (req.method === "HEAD") {
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(
|
||||
req.url ?? "/",
|
||||
`http://${req.headers.host ?? "localhost"}`,
|
||||
);
|
||||
const { pathname, search } = url;
|
||||
|
||||
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
||||
await proxyToBackend(req, res, pathname, search);
|
||||
return;
|
||||
}
|
||||
|
||||
await serveStatic(req, res, pathname === "/" ? "/index.html" : pathname);
|
||||
} catch (error) {
|
||||
sendJson(res, 500, {
|
||||
error: "internal_server_error",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}).listen(port, host, () => {
|
||||
console.log(`Frontend server listening on http://${host}:${port}`);
|
||||
});
|
||||
115
baron-sso/scripts/summarize_flutter_coverage.mjs
Normal file
115
baron-sso/scripts/summarize_flutter_coverage.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const packageName = process.argv[2] || "userfront";
|
||||
const lcovPath = path.join(repoRoot, packageName, "coverage", "lcov.info");
|
||||
const reportsDir = path.join(repoRoot, "reports");
|
||||
|
||||
const excludedSourceFiles = new Set(["lib/main.dart", "lib/i18n_data.dart"]);
|
||||
|
||||
function normalizeSourcePath(sourcePath) {
|
||||
const normalized = sourcePath.replace(/\\/g, "/");
|
||||
const packagePrefix = `${packageName}/`;
|
||||
return normalized.startsWith(packagePrefix)
|
||||
? normalized.slice(packagePrefix.length)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
function shouldExcludeSource(sourcePath) {
|
||||
const normalized = normalizeSourcePath(sourcePath);
|
||||
return (
|
||||
excludedSourceFiles.has(normalized) ||
|
||||
normalized.endsWith(".g.dart") ||
|
||||
normalized.endsWith(".freezed.dart")
|
||||
);
|
||||
}
|
||||
|
||||
function parseLcov(raw) {
|
||||
const records = [];
|
||||
let current = null;
|
||||
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (line.startsWith("SF:")) {
|
||||
current = { sourceFile: normalizeSourcePath(line.slice(3)), lines: [] };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!current) continue;
|
||||
|
||||
if (line.startsWith("DA:")) {
|
||||
const [, hitsValue] = line.slice(3).split(",");
|
||||
const hits = Number(hitsValue);
|
||||
current.lines.push(Number.isFinite(hits) ? hits : 0);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line === "end_of_record") {
|
||||
records.push(current);
|
||||
current = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
records.push(current);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
function formatPct(value) {
|
||||
return `${value.toFixed(2)}%`;
|
||||
}
|
||||
|
||||
function renderMarkdown(row) {
|
||||
return [
|
||||
"# Userfront Flutter Coverage Summary",
|
||||
"",
|
||||
"| Package | Lines | Covered / Total | LCOV |",
|
||||
"| --- | ---: | ---: | --- |",
|
||||
`| ${row.package} | ${formatPct(row.lines)} | ${row.coveredLines} / ${row.totalLines} | ${row.lcovPath} |`,
|
||||
"",
|
||||
"Coverage excludes Flutter bootstrap/generated files: `lib/main.dart`, `lib/i18n_data.dart`, `*.g.dart`, `*.freezed.dart`.",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const lcov = await readFile(lcovPath, "utf8");
|
||||
const includedRecords = parseLcov(lcov).filter(
|
||||
(record) => !shouldExcludeSource(record.sourceFile),
|
||||
);
|
||||
|
||||
const totalLines = includedRecords.reduce(
|
||||
(total, record) => total + record.lines.length,
|
||||
0,
|
||||
);
|
||||
const coveredLines = includedRecords.reduce(
|
||||
(total, record) => total + record.lines.filter((hits) => hits > 0).length,
|
||||
0,
|
||||
);
|
||||
const lineCoverage = totalLines === 0 ? 0 : (coveredLines / totalLines) * 100;
|
||||
|
||||
const row = {
|
||||
package: packageName,
|
||||
statements: lineCoverage,
|
||||
branches: null,
|
||||
functions: null,
|
||||
lines: lineCoverage,
|
||||
coveredLines,
|
||||
totalLines,
|
||||
summaryPath: "reports/package-coverage-summary.json",
|
||||
htmlPath: null,
|
||||
lcovPath: `${packageName}/coverage/lcov.info`,
|
||||
};
|
||||
|
||||
await mkdir(reportsDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(reportsDir, "package-coverage-summary.json"),
|
||||
`${JSON.stringify({ packages: [row] }, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(reportsDir, `${packageName}-coverage-summary.md`),
|
||||
renderMarkdown(row),
|
||||
);
|
||||
|
||||
console.log(renderMarkdown(row));
|
||||
83
baron-sso/scripts/summarize_vitest_coverage.mjs
Normal file
83
baron-sso/scripts/summarize_vitest_coverage.mjs
Normal file
@@ -0,0 +1,83 @@
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const defaultPackages = ["adminfront", "devfront", "orgfront"];
|
||||
const packages = process.argv.slice(2);
|
||||
const targetPackages = packages.length > 0 ? packages : defaultPackages;
|
||||
|
||||
function formatPct(value) {
|
||||
return typeof value === "number" ? `${value.toFixed(2)}%` : "n/a";
|
||||
}
|
||||
|
||||
async function readCoverageSummary(packageName) {
|
||||
const summaryPath = path.join(
|
||||
repoRoot,
|
||||
packageName,
|
||||
"coverage",
|
||||
"coverage-summary.json",
|
||||
);
|
||||
const raw = await readFile(summaryPath, "utf8");
|
||||
const summary = JSON.parse(raw);
|
||||
const total = summary.total;
|
||||
|
||||
return {
|
||||
package: packageName,
|
||||
statements: total.statements.pct,
|
||||
branches: total.branches.pct,
|
||||
functions: total.functions.pct,
|
||||
lines: total.lines.pct,
|
||||
summaryPath: path.relative(repoRoot, summaryPath),
|
||||
htmlPath: `${packageName}/coverage/index.html`,
|
||||
lcovPath: `${packageName}/coverage/lcov.info`,
|
||||
};
|
||||
}
|
||||
|
||||
function renderMarkdown(rows) {
|
||||
const lines = [
|
||||
"# Vitest Coverage Summary",
|
||||
"",
|
||||
"| Package | Statements | Branches | Functions | Lines | HTML Report | LCOV |",
|
||||
"| --- | ---: | ---: | ---: | ---: | --- | --- |",
|
||||
];
|
||||
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
[
|
||||
`| ${row.package}`,
|
||||
formatPct(row.statements),
|
||||
formatPct(row.branches),
|
||||
formatPct(row.functions),
|
||||
formatPct(row.lines),
|
||||
row.htmlPath,
|
||||
`${row.package}/coverage/lcov.info |`,
|
||||
].join(" | "),
|
||||
);
|
||||
}
|
||||
|
||||
lines.push(
|
||||
"",
|
||||
"Coverage includes each app's `src` tree and imported/covered files under `common`.",
|
||||
"",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
const rows = [];
|
||||
for (const packageName of targetPackages) {
|
||||
rows.push(await readCoverageSummary(packageName));
|
||||
}
|
||||
|
||||
const reportsDir = path.join(repoRoot, "reports");
|
||||
await mkdir(reportsDir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(reportsDir, "vitest-coverage-summary.json"),
|
||||
`${JSON.stringify({ packages: rows }, null, 2)}\n`,
|
||||
);
|
||||
await writeFile(
|
||||
path.join(reportsDir, "vitest-coverage-summary.md"),
|
||||
renderMarkdown(rows),
|
||||
);
|
||||
|
||||
console.log(renderMarkdown(rows));
|
||||
47
baron-sso/scripts/sync_userfront_locales.sh
Normal file
47
baron-sso/scripts/sync_userfront_locales.sh
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# 루트 locales/*.toml -> userfront/assets/translations/ 동기화
|
||||
# - userfront에서 사용하는 섹션만 추출
|
||||
# - {{param}} -> {param} 변환 (easy_localization 포맷)
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
src_dir="$repo_root/locales"
|
||||
dest_dir="$repo_root/userfront/assets/translations"
|
||||
|
||||
mkdir -p "$dest_dir"
|
||||
|
||||
filter_toml() {
|
||||
src_file="$1"
|
||||
dest_file="$2"
|
||||
awk '
|
||||
function allowed(section) {
|
||||
return section ~ /^(ui\.userfront|msg\.userfront|err\.userfront|ui\.common|domain)(\.|$)/;
|
||||
}
|
||||
BEGIN { keep = 0; }
|
||||
{
|
||||
line = $0;
|
||||
if (match(line, /^[[:space:]]*\[[^]]+\][[:space:]]*$/)) {
|
||||
section = line;
|
||||
gsub(/^[[:space:]]*\[/, "", section);
|
||||
gsub(/\][[:space:]]*$/, "", section);
|
||||
keep = allowed(section);
|
||||
if (keep) {
|
||||
print line;
|
||||
}
|
||||
next;
|
||||
}
|
||||
if (keep) {
|
||||
print line;
|
||||
}
|
||||
}
|
||||
' "$src_file" \
|
||||
| sed -E 's/\{\{[[:space:]]*([a-zA-Z0-9_]+)[[:space:]]*\}\}/{\1}/g' \
|
||||
> "$dest_file"
|
||||
}
|
||||
|
||||
for file in "$src_dir"/*.toml; do
|
||||
base="$(basename "$file")"
|
||||
filter_toml "$file" "$dest_dir/$base"
|
||||
done
|
||||
|
||||
echo "Synced locales to userfront assets: $dest_dir"
|
||||
36
baron-sso/scripts/test_frontend_runtime_mode.sh
Normal file
36
baron-sso/scripts/test_frontend_runtime_mode.sh
Normal file
@@ -0,0 +1,36 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
assert_mode() {
|
||||
script_path="$1"
|
||||
app_env="$2"
|
||||
expected="$3"
|
||||
actual="$(APP_ENV="$app_env" sh "$script_path" --print-mode)"
|
||||
if [ "$actual" != "$expected" ]; then
|
||||
echo "script=$script_path APP_ENV=$app_env expected mode=$expected got=$actual" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
for script in \
|
||||
"./adminfront/scripts/runtime-mode.sh" \
|
||||
"./devfront/scripts/runtime-mode.sh" \
|
||||
"./orgfront/scripts/runtime-mode.sh"
|
||||
do
|
||||
if ! grep -Fq "ensure_frontend_dependencies" "$script"; then
|
||||
echo "script=$script must sync frontend dependencies before start" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq "package-lock.json" "$script"; then
|
||||
echo "script=$script must use package-lock.json for dependency sync" >&2
|
||||
exit 1
|
||||
fi
|
||||
assert_mode "$script" "production" "production"
|
||||
assert_mode "$script" "prod" "production"
|
||||
assert_mode "$script" "stage" "production"
|
||||
assert_mode "$script" "staging" "production"
|
||||
assert_mode "$script" "development" "development"
|
||||
assert_mode "$script" "dev" "development"
|
||||
done
|
||||
|
||||
echo "frontend runtime mode checks passed"
|
||||
39
baron-sso/scripts/test_staging_workflow_env.sh
Normal file
39
baron-sso/scripts/test_staging_workflow_env.sh
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
assert_contains() {
|
||||
file="$1"
|
||||
pattern="$2"
|
||||
if ! grep -Fq "$pattern" "$file"; then
|
||||
echo "missing pattern in $file: $pattern" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
for workflow in \
|
||||
".gitea/workflows/staging_code_pull.yml" \
|
||||
".gitea/workflows/staging_release.yml"
|
||||
do
|
||||
assert_contains "$workflow" "APP_ENV=stage"
|
||||
assert_contains "$workflow" "BACKEND_LOG_LEVEL=debug"
|
||||
assert_contains "$workflow" "CLIENT_LOG_DEBUG=true"
|
||||
assert_contains "$workflow" 'WORKS_ADMIN_API_BASE_URL=${{ vars.WORKS_ADMIN_API_BASE_URL }}'
|
||||
assert_contains "$workflow" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${{ vars.WORKS_ADMIN_OAUTH_TOKEN_URL }}'
|
||||
assert_contains "$workflow" 'BACKEND_PUBLIC_URL=${{ vars.BACKEND_URL }}'
|
||||
assert_contains "$workflow" 'ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=${{ vars.ORGFRONT_ORGCHART_CACHE_TTL_SECONDS }}'
|
||||
assert_contains "$workflow" "ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=3600"
|
||||
done
|
||||
|
||||
assert_contains ".gitea/workflows/staging_release.yml" "scp adminfront/seed-tenant.csv"
|
||||
assert_contains "docker-compose.yaml" 'WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL}'
|
||||
assert_contains "docker-compose.yaml" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL}'
|
||||
assert_contains "docker/docker-compose.staging.template.yaml" "SEED_TENANT_CSV_PATH=/app/seed-tenant.csv"
|
||||
assert_contains "docker/docker-compose.staging.template.yaml" "./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro"
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" "SEED_TENANT_CSV_PATH=/app/seed-tenant.csv"
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" "./adminfront/seed-tenant.csv:/app/seed-tenant.csv:ro"
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" 'WORKS_ADMIN_API_BASE_URL=${WORKS_ADMIN_API_BASE_URL}'
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" 'WORKS_ADMIN_OAUTH_TOKEN_URL=${WORKS_ADMIN_OAUTH_TOKEN_URL}'
|
||||
assert_contains "docker/docker-compose.staging.template.yaml" "ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=\${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}"
|
||||
assert_contains "docker/staging_pull_compose.template.yaml" "ORGFRONT_ORGCHART_CACHE_TTL_SECONDS=\${ORGFRONT_ORGCHART_CACHE_TTL_SECONDS:-3600}"
|
||||
|
||||
echo "staging workflow env checks passed"
|
||||
461
baron-sso/scripts/update_code_check_badges.mjs
Normal file
461
baron-sso/scripts/update_code_check_badges.mjs
Normal file
@@ -0,0 +1,461 @@
|
||||
import { mkdir, readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const badgeDir = path.join(repoRoot, "docs", "badges");
|
||||
const manifestPath = path.join(badgeDir, "badges.json");
|
||||
|
||||
const resultStyles = {
|
||||
success: { message: "passing", color: "#2ea043" },
|
||||
failure: { message: "failing", color: "#cf222e" },
|
||||
cancelled: { message: "cancelled", color: "#bf8700" },
|
||||
skipped: { message: "skipped", color: "#6e7781" },
|
||||
unknown: { message: "unknown", color: "#6e7781" },
|
||||
};
|
||||
|
||||
const badgeDefinitions = {
|
||||
"dev-sha": { label: "dev", message: "unknown", color: "#0969da" },
|
||||
"code-check": { label: "code check", message: "unknown", color: "#6e7781" },
|
||||
biome: { label: "biome", message: "unknown", color: "#6e7781" },
|
||||
"backend-tests": {
|
||||
label: "backend",
|
||||
message: "unknown",
|
||||
color: "#6e7781",
|
||||
},
|
||||
userfront: {
|
||||
label: "userfront",
|
||||
message: "unknown",
|
||||
color: "#6e7781",
|
||||
},
|
||||
adminfront: {
|
||||
label: "adminfront",
|
||||
message: "unknown",
|
||||
color: "#6e7781",
|
||||
},
|
||||
devfront: {
|
||||
label: "devfront",
|
||||
message: "unknown",
|
||||
color: "#6e7781",
|
||||
},
|
||||
orgfront: {
|
||||
label: "orgfront",
|
||||
message: "unknown",
|
||||
color: "#6e7781",
|
||||
},
|
||||
"userfront-chrome": {
|
||||
label: "chrome",
|
||||
message: "unknown",
|
||||
color: "#6e7781",
|
||||
},
|
||||
"userfront-firefox": {
|
||||
label: "firefox",
|
||||
message: "unknown",
|
||||
color: "#6e7781",
|
||||
},
|
||||
"userfront-safari": {
|
||||
label: "safari",
|
||||
message: "unknown",
|
||||
color: "#6e7781",
|
||||
},
|
||||
};
|
||||
|
||||
const deprecatedBadgeKeys = [
|
||||
"userfront-e2e-fast",
|
||||
"userfront-e2e-full",
|
||||
"adminfront-e2e",
|
||||
"devfront-e2e",
|
||||
"orgfront-e2e",
|
||||
"userfront-coverage",
|
||||
"adminfront-coverage",
|
||||
"devfront-coverage",
|
||||
"orgfront-coverage",
|
||||
];
|
||||
|
||||
function normalizeResult(result) {
|
||||
return resultStyles[result] ? result : "unknown";
|
||||
}
|
||||
|
||||
function styleForResult(result) {
|
||||
return resultStyles[normalizeResult(result)];
|
||||
}
|
||||
|
||||
function compactResult(result) {
|
||||
const normalized = normalizeResult(result);
|
||||
return {
|
||||
success: "pass",
|
||||
failure: "fail",
|
||||
cancelled: "cancel",
|
||||
skipped: "skip",
|
||||
unknown: "unknown",
|
||||
}[normalized];
|
||||
}
|
||||
|
||||
function colorForParts(parts) {
|
||||
const normalized = parts.map(normalizeResult);
|
||||
if (normalized.includes("failure")) return resultStyles.failure.color;
|
||||
if (normalized.includes("cancelled")) return resultStyles.cancelled.color;
|
||||
if (normalized.every((part) => part === "success"))
|
||||
return resultStyles.success.color;
|
||||
return resultStyles.unknown.color;
|
||||
}
|
||||
|
||||
function colorForCoverage(percent) {
|
||||
if (percent >= 80) return "#2ea043";
|
||||
if (percent >= 35) return "#bf8700";
|
||||
return "#cf222e";
|
||||
}
|
||||
|
||||
function escapeXml(value) {
|
||||
return String(value)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function textWidth(text) {
|
||||
return Math.max(38, Math.ceil(String(text).length * 6.8 + 10));
|
||||
}
|
||||
|
||||
function renderBadge({ label, message, color }) {
|
||||
const labelWidth = textWidth(label);
|
||||
const messageWidth = textWidth(message);
|
||||
const width = labelWidth + messageWidth;
|
||||
const labelCenter = labelWidth / 2;
|
||||
const messageCenter = labelWidth + messageWidth / 2;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="20" role="img" aria-label="${escapeXml(label)}: ${escapeXml(message)}">
|
||||
<title>${escapeXml(label)}: ${escapeXml(message)}</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset="1" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="${width}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="${labelWidth}" height="20" fill="#555"/>
|
||||
<rect x="${labelWidth}" width="${messageWidth}" height="20" fill="${color}"/>
|
||||
<rect width="${width}" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
|
||||
<text x="${labelCenter}" y="15" fill="#010101" fill-opacity=".3">${escapeXml(label)}</text>
|
||||
<text x="${labelCenter}" y="14">${escapeXml(label)}</text>
|
||||
<text x="${messageCenter}" y="15" fill="#010101" fill-opacity=".3">${escapeXml(message)}</text>
|
||||
<text x="${messageCenter}" y="14">${escapeXml(message)}</text>
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
|
||||
async function readJsonIfExists(filePath) {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function findCoverageSummaries(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(
|
||||
() => [],
|
||||
);
|
||||
const results = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
if (
|
||||
entry.isFile() &&
|
||||
[
|
||||
"backend-coverage-summary.json",
|
||||
"package-coverage-summary.json",
|
||||
"vitest-coverage-summary.json",
|
||||
].includes(entry.name)
|
||||
) {
|
||||
results.push(entryPath);
|
||||
continue;
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...(await findCoverageSummaries(entryPath)));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function updateResultBadge(manifest, key, result) {
|
||||
const style = styleForResult(result);
|
||||
manifest.badges[key] = {
|
||||
...(manifest.badges[key] ?? badgeDefinitions[key]),
|
||||
message: style.message,
|
||||
color: style.color,
|
||||
};
|
||||
}
|
||||
|
||||
function updateCompactResultBadge(manifest, key, result) {
|
||||
const style = styleForResult(result);
|
||||
manifest.badges[key] = {
|
||||
...(manifest.badges[key] ?? badgeDefinitions[key]),
|
||||
label: badgeDefinitions[key]?.label ?? manifest.badges[key]?.label,
|
||||
message: compactResult(result),
|
||||
color: style.color,
|
||||
};
|
||||
}
|
||||
|
||||
function coveragePart(result, statements) {
|
||||
const normalized = normalizeResult(result);
|
||||
if (normalized !== "success") {
|
||||
return {
|
||||
message: compactResult(normalized),
|
||||
color: styleForResult(normalized).color,
|
||||
result: normalized,
|
||||
};
|
||||
}
|
||||
const value = Number(statements);
|
||||
if (!Number.isFinite(value)) {
|
||||
return {
|
||||
message: "unknown",
|
||||
color: resultStyles.unknown.color,
|
||||
result: "unknown",
|
||||
};
|
||||
}
|
||||
return {
|
||||
message: `${value.toFixed(2)}%`,
|
||||
color: colorForCoverage(value),
|
||||
result: "success",
|
||||
};
|
||||
}
|
||||
|
||||
function updatePackageBadge(
|
||||
manifest,
|
||||
key,
|
||||
testResult,
|
||||
coverageResult,
|
||||
statements,
|
||||
) {
|
||||
if (!badgeDefinitions[key]) return;
|
||||
const test = normalizeResult(testResult);
|
||||
const coverage = coveragePart(coverageResult, statements);
|
||||
manifest.badges[key] = {
|
||||
...(manifest.badges[key] ?? badgeDefinitions[key]),
|
||||
label: badgeDefinitions[key].label,
|
||||
message: `${compactResult(test)} | ${coverage.message}`,
|
||||
color:
|
||||
test === "failure" || coverage.result === "failure"
|
||||
? resultStyles.failure.color
|
||||
: test === "cancelled" || coverage.result === "cancelled"
|
||||
? resultStyles.cancelled.color
|
||||
: coverage.result === "success"
|
||||
? coverage.color
|
||||
: colorForParts([test, coverage.result]),
|
||||
};
|
||||
}
|
||||
|
||||
function updateBrowserBadge(manifest, key, desktopResult, mobileResult) {
|
||||
if (!badgeDefinitions[key]) return;
|
||||
const desktop = normalizeResult(desktopResult);
|
||||
const mobile = normalizeResult(mobileResult);
|
||||
manifest.badges[key] = {
|
||||
...(manifest.badges[key] ?? badgeDefinitions[key]),
|
||||
label: badgeDefinitions[key].label,
|
||||
message: `${compactResult(desktop)} | ${compactResult(mobile)}`,
|
||||
color: colorForParts([desktop, mobile]),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeManifestForComparison(value) {
|
||||
return {
|
||||
...value,
|
||||
updatedAt: null,
|
||||
source: {
|
||||
...(value?.source ?? {}),
|
||||
runId: null,
|
||||
runNumber: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function manifestsMatchIgnoringRunMetadata(left, right) {
|
||||
return (
|
||||
JSON.stringify(normalizeManifestForComparison(left)) ===
|
||||
JSON.stringify(normalizeManifestForComparison(right))
|
||||
);
|
||||
}
|
||||
|
||||
function shortSha(value) {
|
||||
return String(value ?? "")
|
||||
.trim()
|
||||
.slice(0, 12);
|
||||
}
|
||||
|
||||
const existingManifest =
|
||||
process.env.RESET_BADGES === "true"
|
||||
? null
|
||||
: await readJsonIfExists(manifestPath);
|
||||
const sourceSha = shortSha(
|
||||
process.env.BADGE_SOURCE_SHA || process.env.GITHUB_SHA,
|
||||
);
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
generatedBy: "scripts/update_code_check_badges.mjs",
|
||||
updatedAt: new Date().toISOString(),
|
||||
source: {
|
||||
branch: process.env.BADGE_SOURCE_BRANCH || "dev",
|
||||
sha: process.env.BADGE_SOURCE_SHA || process.env.GITHUB_SHA || null,
|
||||
shortSha: sourceSha || null,
|
||||
runId: process.env.GITHUB_RUN_ID || null,
|
||||
runNumber: process.env.GITHUB_RUN_NUMBER || null,
|
||||
},
|
||||
badges: {
|
||||
...badgeDefinitions,
|
||||
...(existingManifest?.badges ?? {}),
|
||||
},
|
||||
};
|
||||
for (const key of deprecatedBadgeKeys) {
|
||||
delete manifest.badges[key];
|
||||
}
|
||||
|
||||
manifest.badges["dev-sha"] = {
|
||||
...badgeDefinitions["dev-sha"],
|
||||
message: sourceSha || "unknown",
|
||||
};
|
||||
|
||||
const jobResults = {
|
||||
lint: process.env.LINT_RESULT,
|
||||
biome: process.env.BIOME_RESULT,
|
||||
backend: process.env.BACKEND_RESULT,
|
||||
userfront: process.env.USERFRONT_RESULT,
|
||||
userfrontE2e: process.env.USERFRONT_E2E_RESULT,
|
||||
adminfront: process.env.ADMINFRONT_RESULT,
|
||||
devfront: process.env.DEVFRONT_RESULT,
|
||||
orgfront: process.env.ORGFRONT_RESULT,
|
||||
};
|
||||
const e2eWasFull = process.env.USERFRONT_E2E_FULL === "true";
|
||||
const userfrontFastResult = e2eWasFull ? undefined : jobResults.userfrontE2e;
|
||||
const browserResults = {
|
||||
chrome: {
|
||||
desktop: process.env.USERFRONT_E2E_CHROMIUM_DESKTOP_RESULT,
|
||||
mobile: process.env.USERFRONT_E2E_CHROMIUM_MOBILE_RESULT,
|
||||
},
|
||||
firefox: {
|
||||
desktop: process.env.USERFRONT_E2E_FIREFOX_DESKTOP_RESULT,
|
||||
mobile: process.env.USERFRONT_E2E_FIREFOX_MOBILE_RESULT,
|
||||
},
|
||||
safari: {
|
||||
desktop: process.env.USERFRONT_E2E_WEBKIT_DESKTOP_RESULT,
|
||||
mobile: process.env.USERFRONT_E2E_WEBKIT_MOBILE_RESULT,
|
||||
},
|
||||
};
|
||||
|
||||
const legacyCoverageResult = process.env.COVERAGE_RESULT;
|
||||
const coverageJobResults = {
|
||||
backend: process.env.BACKEND_COVERAGE_RESULT,
|
||||
userfront: process.env.USERFRONT_COVERAGE_RESULT,
|
||||
adminfront: process.env.ADMINFRONT_COVERAGE_RESULT || legacyCoverageResult,
|
||||
devfront: process.env.DEVFRONT_COVERAGE_RESULT || legacyCoverageResult,
|
||||
orgfront: process.env.ORGFRONT_COVERAGE_RESULT || legacyCoverageResult,
|
||||
};
|
||||
|
||||
const overallResults = [
|
||||
...Object.values(jobResults),
|
||||
...Object.values(coverageJobResults),
|
||||
...Object.values(browserResults).flatMap((result) => [
|
||||
result.desktop,
|
||||
result.mobile,
|
||||
]),
|
||||
].filter(Boolean);
|
||||
const hasFailure = overallResults.some((result) =>
|
||||
["failure", "cancelled"].includes(result),
|
||||
);
|
||||
const allSkipped =
|
||||
overallResults.length > 0 &&
|
||||
overallResults.every((result) => result === "skipped");
|
||||
if (process.env.BADGE_UPDATE_CODE_CHECK !== "false") {
|
||||
updateResultBadge(
|
||||
manifest,
|
||||
"code-check",
|
||||
overallResults.length === 0
|
||||
? "unknown"
|
||||
: hasFailure
|
||||
? "failure"
|
||||
: allSkipped
|
||||
? "skipped"
|
||||
: "success",
|
||||
);
|
||||
}
|
||||
|
||||
if (jobResults.biome) {
|
||||
updateResultBadge(manifest, "biome", jobResults.biome);
|
||||
}
|
||||
|
||||
const coverageSummaries = process.env.COVERAGE_SUMMARY_PATH
|
||||
? [process.env.COVERAGE_SUMMARY_PATH]
|
||||
: await findCoverageSummaries(path.join(repoRoot, "badge-artifacts"));
|
||||
const coverageByPackage = new Map();
|
||||
for (const summaryPath of coverageSummaries) {
|
||||
const coverageSummary = await readJsonIfExists(summaryPath);
|
||||
for (const row of coverageSummary?.packages ?? []) {
|
||||
coverageByPackage.set(row.package, row.statements);
|
||||
}
|
||||
if (coverageSummary?.package) {
|
||||
coverageByPackage.set(coverageSummary.package, coverageSummary.statements);
|
||||
}
|
||||
}
|
||||
|
||||
if (coverageJobResults.backend) {
|
||||
updatePackageBadge(
|
||||
manifest,
|
||||
"backend-tests",
|
||||
jobResults.backend,
|
||||
coverageJobResults.backend,
|
||||
coverageByPackage.get("backend"),
|
||||
);
|
||||
} else if (jobResults.backend) {
|
||||
updateCompactResultBadge(manifest, "backend-tests", jobResults.backend);
|
||||
}
|
||||
|
||||
for (const [key, testResult, coverageResult] of [
|
||||
["userfront", userfrontFastResult, coverageJobResults.userfront],
|
||||
["adminfront", jobResults.adminfront, coverageJobResults.adminfront],
|
||||
["devfront", jobResults.devfront, coverageJobResults.devfront],
|
||||
["orgfront", jobResults.orgfront, coverageJobResults.orgfront],
|
||||
]) {
|
||||
if (testResult || coverageResult) {
|
||||
updatePackageBadge(
|
||||
manifest,
|
||||
key,
|
||||
testResult,
|
||||
coverageResult,
|
||||
coverageByPackage.get(key),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [browser, result] of Object.entries(browserResults)) {
|
||||
if (result.desktop || result.mobile) {
|
||||
updateBrowserBadge(
|
||||
manifest,
|
||||
`userfront-${browser}`,
|
||||
result.desktop,
|
||||
result.mobile,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
existingManifest &&
|
||||
manifestsMatchIgnoringRunMetadata(manifest, existingManifest)
|
||||
) {
|
||||
manifest.updatedAt = existingManifest.updatedAt;
|
||||
manifest.source = existingManifest.source ?? manifest.source;
|
||||
}
|
||||
|
||||
await mkdir(badgeDir, { recursive: true });
|
||||
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
for (const [key, badge] of Object.entries(manifest.badges)) {
|
||||
await writeFile(path.join(badgeDir, `${key}.svg`), renderBadge(badge));
|
||||
}
|
||||
for (const key of deprecatedBadgeKeys) {
|
||||
await unlink(path.join(badgeDir, `${key}.svg`)).catch(() => {});
|
||||
}
|
||||
|
||||
console.log(`Updated ${Object.keys(manifest.badges).length} badge files.`);
|
||||
98
baron-sso/scripts/verify_userfront_error_i18n.sh
Normal file
98
baron-sso/scripts/verify_userfront_error_i18n.sh
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
whitelist_file="$repo_root/userfront/lib/core/constants/error_whitelist.dart"
|
||||
|
||||
extract_internal_codes() {
|
||||
awk '
|
||||
/internalErrorWhitelistMessages/ { in_map=1; next }
|
||||
in_map && /\};/ { in_map=0 }
|
||||
in_map {
|
||||
if (match($0, /'\''([a-z0-9_]+)'\''[[:space:]]*:/, m)) {
|
||||
print m[1]
|
||||
}
|
||||
}
|
||||
' "$whitelist_file"
|
||||
}
|
||||
|
||||
extract_ory_codes() {
|
||||
awk '
|
||||
/oryBypassErrorCodes/ { in_set=1; next }
|
||||
in_set && /\};/ { in_set=0 }
|
||||
in_set {
|
||||
if (match($0, /'\''([a-z0-9_]+)'\''/, m)) {
|
||||
print m[1]
|
||||
}
|
||||
}
|
||||
' "$whitelist_file"
|
||||
}
|
||||
|
||||
extract_toml_section_keys() {
|
||||
local file="$1"
|
||||
local section="$2"
|
||||
awk -v section="$section" '
|
||||
/^[[:space:]]*\[[^]]+\][[:space:]]*$/ {
|
||||
current=$0
|
||||
gsub(/^[[:space:]]*\[/, "", current)
|
||||
gsub(/\][[:space:]]*$/, "", current)
|
||||
in_section=(current == section)
|
||||
next
|
||||
}
|
||||
|
||||
in_section {
|
||||
if (match($0, /^[[:space:]]*([a-zA-Z0-9_]+)[[:space:]]*=/, m)) {
|
||||
print m[1]
|
||||
}
|
||||
}
|
||||
' "$file"
|
||||
}
|
||||
|
||||
check_section_keys() {
|
||||
local expected_keys="$1"
|
||||
local section="$2"
|
||||
local file="$3"
|
||||
local missing
|
||||
|
||||
missing="$(comm -23 \
|
||||
<(printf '%s\n' "$expected_keys" | sed '/^$/d' | sort -u) \
|
||||
<(extract_toml_section_keys "$file" "$section" | sort -u) \
|
||||
)"
|
||||
|
||||
if [[ -n "$missing" ]]; then
|
||||
echo "[FAIL] ${file} -> [${section}] 누락 키:"
|
||||
printf '%s\n' "$missing" | sed 's/^/ - /'
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "[OK] ${file} -> [${section}]"
|
||||
}
|
||||
|
||||
internal_codes="$(extract_internal_codes)"
|
||||
ory_codes="$(extract_ory_codes)"
|
||||
|
||||
if [[ -z "$internal_codes" ]]; then
|
||||
echo "[FAIL] 내부 whitelist 코드를 찾지 못했습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$ory_codes" ]]; then
|
||||
echo "[FAIL] ORY bypass 코드를 찾지 못했습니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
files=(
|
||||
"$repo_root/locales/template.toml"
|
||||
"$repo_root/locales/ko.toml"
|
||||
"$repo_root/locales/en.toml"
|
||||
"$repo_root/userfront/assets/translations/template.toml"
|
||||
"$repo_root/userfront/assets/translations/ko.toml"
|
||||
"$repo_root/userfront/assets/translations/en.toml"
|
||||
)
|
||||
|
||||
for file in "${files[@]}"; do
|
||||
check_section_keys "$internal_codes" "msg.userfront.error.whitelist" "$file"
|
||||
check_section_keys "$ory_codes" "msg.userfront.error.ory" "$file"
|
||||
done
|
||||
|
||||
echo "모든 에러 코드 i18n 키 검증이 완료되었습니다."
|
||||
Reference in New Issue
Block a user