Add platform infrastructure configuration

This commit is contained in:
donghyeon-ka
2026-08-28 17:35:41 +09:00
parent fa76531e5b
commit 16c337bcc9
302 changed files with 83259 additions and 1 deletions
+400
View File
@@ -0,0 +1,400 @@
#!/usr/bin/env bash
set -Eeuo pipefail
set +x
umask 077
readonly ROOT="$(cd -- "$(dirname -- "$BASH_SOURCE")/../.." && pwd -P)"
readonly KC_NS=keycloak
readonly KC_NAME=keycloak
readonly KC_SERVICE=keycloak-service
readonly KC_HOST=id.learn.hyeonworks.com
readonly REALM=hyeonworks
readonly PORT=18081
readonly BASE=http://127.0.0.1:$PORT
execute=false
object_admin=""
db_admin=""
temp=""
pf=""
rollback_armed=false
ai_client_created=false
pg_client_created=false
ai_group_created=false
pg_group_created=false
ai_member_added=false
pg_member_added=false
ai_client_uuid=""
pg_client_uuid=""
ai_group_uuid=""
pg_group_uuid=""
ai_user_uuid=""
pg_user_uuid=""
fail() {
printf 'ERROR: %s\n' "$*" >&2
exit 1
}
usage() {
cat <<'USAGE'
사용법:
bash scripts/bootstrap/configure-keycloak-admin-oidc.sh
bash scripts/bootstrap/configure-keycloak-admin-oidc.sh --execute \
--object-admin donghyeon.kang --db-admin donghyeon.kang
두 confidential client, 두 관리자 그룹, claim mapper와 다음 Secret을 만듭니다.
- object-storage/aistor-keycloak-oidc: client-id, client-secret
- platform-admin/pgadmin-keycloak-oidc: client-id, client-secret
USAGE
}
while (( $# > 0 )); do
case "$1" in
--execute) execute=true; shift ;;
--object-admin)
(( $# >= 2 )) || fail "--object-admin 값이 필요합니다"
object_admin="$2"; shift 2 ;;
--db-admin)
(( $# >= 2 )) || fail "--db-admin 값이 필요합니다"
db_admin="$2"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) usage >&2; fail "지원하지 않는 인자: $1" ;;
esac
done
[[ "$(pwd -P)" == "$ROOT" ]] || fail "$ROOT에서 실행하세요"
for cmd in base64 chmod curl jq kill kubectl mktemp rg rm seq sleep sort tr; do
command -v "$cmd" >/dev/null 2>&1 || fail "$cmd 명령이 필요합니다"
done
context="$(kubectl config current-context)"
api="$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')"
kubectl -n "$KC_NS" wait --for=condition=Ready "keycloak.k8s.keycloak.org/$KC_NAME" --timeout=30s >/dev/null
kubectl -n "$KC_NS" get "secret/$KC_NAME-initial-admin" >/dev/null
printf 'Current context: %s\nRealm: %s\n' "$context" "$REALM"
printf 'Clients: aistor-console, pgadmin\n'
printf 'Groups: /platform-object-admins, /platform-db-admins\n'
[[ -n "$object_admin" ]] && printf 'Object admin: %s\n' "$object_admin"
[[ -n "$db_admin" ]] && printf 'DB admin: %s\n' "$db_admin"
if [[ "$execute" == false ]]; then
printf 'DRY RUN PASS: --execute를 지정하지 않아 변경하지 않았습니다.\n'
exit 0
fi
[[ -t 0 ]] || fail "--execute는 대화형 터미널이 필요합니다"
printf 'Type APPLY %s to configure admin OIDC: ' "$context"
read -r answer
[[ "$answer" == "APPLY $context" ]] || fail "취소했습니다"
[[ "$(kubectl config current-context)" == "$context" ]] || fail "context가 바뀌었습니다"
[[ "$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')" == "$api" ]] || fail "API server가 바뀌었습니다"
kubectl apply -k "$ROOT/infrastructure/namespaces/admin-tools" >/dev/null
temp="$(mktemp -d /tmp/keycloak-admin-oidc.XXXXXX)"
admin_user="$temp/admin-user"
admin_password="$temp/admin-password"
token_json="$temp/token.json"
token="$temp/token"
auth="$temp/auth.conf"
response="$temp/response.json"
early_cleanup() {
rc=$?
trap - EXIT INT TERM
if [[ -n "$pf" ]] && kill -0 "$pf" 2>/dev/null; then
kill "$pf" 2>/dev/null || true
wait "$pf" 2>/dev/null || true
fi
case "$temp" in /tmp/keycloak-admin-oidc.*) rm -rf -- "$temp" ;; esac
exit "$rc"
}
trap early_cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
kubectl -n "$KC_NS" get "secret/$KC_NAME-initial-admin" \
-o jsonpath='{.data.username}' | base64 -d >"$admin_user"
kubectl -n "$KC_NS" get "secret/$KC_NAME-initial-admin" \
-o jsonpath='{.data.password}' | base64 -d >"$admin_password"
chmod 0600 "$admin_user" "$admin_password"
kubectl -n "$KC_NS" port-forward --address=127.0.0.1 "service/$KC_SERVICE" \
"$PORT:8080" >"$temp/port-forward.log" 2>&1 &
pf=$!
ready=false
for _ in $(seq 1 30); do
kill -0 "$pf" 2>/dev/null || fail "Keycloak port-forward가 종료됐습니다"
if rg -q -F "Forwarding from 127.0.0.1:$PORT -> 8080" "$temp/port-forward.log"; then
ready=true
break
fi
sleep 1
done
[[ "$ready" == true ]] || fail "Keycloak port-forward가 준비되지 않았습니다"
kc_curl() {
curl --disable --silent --show-error --noproxy '*' \
--connect-timeout 3 --max-time 20 \
--header "Host: $KC_HOST" \
--header 'X-Forwarded-Proto: https' \
--header 'X-Forwarded-Port: 443' "$@"
}
kc_curl --fail-with-body --output "$token_json" \
--data-urlencode grant_type=password --data-urlencode client_id=admin-cli \
--data-urlencode "username@$admin_user" --data-urlencode "password@$admin_password" \
"$BASE/realms/master/protocol/openid-connect/token" >/dev/null
jq -er '.access_token | select(type == "string" and length > 0)' "$token_json" >"$token"
{
printf 'header = "Authorization: Bearer '
tr -d '\r\n' <"$token"
printf '"\n'
} >"$auth"
chmod 0600 "$token" "$auth"
request() {
kc_curl --config "$auth" "$@"
}
backup_secret() {
if kubectl -n "$1" get "secret/$2" -o yaml >"$3" 2>/dev/null; then
printf true
else
printf false
fi
}
ai_secret_existed="$(backup_secret object-storage aistor-keycloak-oidc "$temp/ai-secret.yaml")"
pg_secret_existed="$(backup_secret platform-admin pgadmin-keycloak-oidc "$temp/pg-secret.yaml")"
upsert_group() {
group_name="$1"
policy="$2"
before="$3"
body="$temp/group.json"
request --get --data-urlencode "search=$group_name" --data-urlencode exact=true \
-o "$response" "$BASE/admin/realms/$REALM/groups"
group_id="$(jq -r --arg path "/$group_name" '[.[] | select(.path == $path)][0].id // empty' "$response")"
if [[ -n "$policy" ]]; then
jq -n --arg name "$group_name" --arg policy "$policy" \
'{name:$name,attributes:{policy:[$policy]}}' >"$body"
else
jq -n --arg name "$group_name" '{name:$name}' >"$body"
fi
if [[ -z "$group_id" ]]; then
code="$(request -X POST -H 'Content-Type: application/json' --data-binary "@$body" \
-o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/groups")"
[[ "$code" == 201 ]] || fail "그룹 생성 실패: $group_name HTTP $code"
request --get --data-urlencode "search=$group_name" --data-urlencode exact=true \
-o "$response" "$BASE/admin/realms/$REALM/groups"
group_id="$(jq -er --arg path "/$group_name" '[.[] | select(.path == $path)][0].id' "$response")"
last_created=true
else
request -o "$before" "$BASE/admin/realms/$REALM/groups/$group_id"
code="$(request -X PUT -H 'Content-Type: application/json' --data-binary "@$body" \
-o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/groups/$group_id")"
[[ "$code" == 204 ]] || fail "그룹 갱신 실패: $group_name HTTP $code"
last_created=false
fi
last_id="$group_id"
}
upsert_client() {
client="$1"
display="$2"
root_url="$3"
redirect="$4"
before="$5"
body="$temp/client.json"
jq -n --arg client "$client" --arg display "$display" --arg root "$root_url" \
--arg redirect "$redirect" '{
clientId:$client,name:$display,enabled:true,protocol:"openid-connect",
clientAuthenticatorType:"client-secret",publicClient:false,
standardFlowEnabled:true,implicitFlowEnabled:false,directAccessGrantsEnabled:false,
serviceAccountsEnabled:false,authorizationServicesEnabled:false,
consentRequired:false,fullScopeAllowed:false,rootUrl:$root,baseUrl:($root+"/"),
redirectUris:[$redirect],webOrigins:[$root],
attributes:{"oauth2.device.authorization.grant.enabled":"false",
"oidc.ciba.grant.enabled":"false","post.logout.redirect.uris":($root+"/*")}
}' >"$body"
request --get --data-urlencode "clientId=$client" --data-urlencode max=2 \
-o "$response" "$BASE/admin/realms/$REALM/clients"
count="$(jq 'length' "$response")"
[[ "$count" == 0 || "$count" == 1 ]] || fail "$client clientId가 중복됐습니다"
if [[ "$count" == 0 ]]; then
code="$(request -X POST -H 'Content-Type: application/json' --data-binary "@$body" \
-o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/clients")"
[[ "$code" == 201 ]] || fail "client 생성 실패: $client HTTP $code"
last_created=true
else
client_id="$(jq -er '.[0].id' "$response")"
request -o "$before" "$BASE/admin/realms/$REALM/clients/$client_id"
code="$(request -X PUT -H 'Content-Type: application/json' --data-binary "@$body" \
-o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/clients/$client_id")"
[[ "$code" == 204 ]] || fail "client 갱신 실패: $client HTTP $code"
last_created=false
fi
request --get --data-urlencode "clientId=$client" --data-urlencode max=2 \
-o "$response" "$BASE/admin/realms/$REALM/clients"
last_id="$(jq -er 'select(length == 1) | .[0].id' "$response")"
}
upsert_mapper() {
client_id="$1"
mapper_name="$2"
mapper_type="$3"
body="$temp/mapper.json"
if [[ "$mapper_type" == group ]]; then
jq -n --arg name "$mapper_name" '{
name:$name,protocol:"openid-connect",protocolMapper:"oidc-group-membership-mapper",
consentRequired:false,config:{"claim.name":"groups","full.path":"true",
"id.token.claim":"true","access.token.claim":"true",
"userinfo.token.claim":"true","introspection.token.claim":"true"}
}' >"$body"
else
jq -n --arg name "$mapper_name" '{
name:$name,protocol:"openid-connect",protocolMapper:"oidc-usermodel-attribute-mapper",
consentRequired:false,config:{"user.attribute":"policy","claim.name":"policy",
"jsonType.label":"String","multivalued":"true","aggregate.attrs":"true",
"id.token.claim":"true","access.token.claim":"true",
"userinfo.token.claim":"true","introspection.token.claim":"true"}
}' >"$body"
fi
request -o "$response" "$BASE/admin/realms/$REALM/clients/$client_id/protocol-mappers/models"
mapper_id="$(jq -r --arg name "$mapper_name" '[.[] | select(.name == $name)][0].id // empty' "$response")"
if [[ -z "$mapper_id" ]]; then
code="$(request -X POST -H 'Content-Type: application/json' --data-binary "@$body" \
-o "$response" -w '%{http_code}' "$BASE/admin/realms/$REALM/clients/$client_id/protocol-mappers/models")"
[[ "$code" == 201 ]] || fail "mapper 생성 실패: $mapper_name HTTP $code"
else
code="$(request -X PUT -H 'Content-Type: application/json' --data-binary "@$body" \
-o "$response" -w '%{http_code}' \
"$BASE/admin/realms/$REALM/clients/$client_id/protocol-mappers/models/$mapper_id")"
[[ "$code" == 204 ]] || fail "mapper 갱신 실패: $mapper_name HTTP $code"
fi
}
assign_member() {
username="$1"
group_id="$2"
request --get --data-urlencode "username=$username" --data-urlencode exact=true \
-o "$response" "$BASE/admin/realms/$REALM/users"
[[ "$(jq 'length' "$response")" == 1 ]] || fail "사용자를 정확히 찾지 못했습니다: $username"
user_id="$(jq -er '.[0].id' "$response")"
request -o "$response" "$BASE/admin/realms/$REALM/users/$user_id/groups"
if jq -e --arg id "$group_id" 'any(.[]; .id == $id)' "$response" >/dev/null; then
last_member_added=false
else
code="$(request -X PUT -o "$response" -w '%{http_code}' \
"$BASE/admin/realms/$REALM/users/$user_id/groups/$group_id")"
[[ "$code" == 204 ]] || fail "그룹 구성원 추가 실패: $username HTTP $code"
last_member_added=true
fi
last_user_id="$user_id"
}
restore_secret() {
if [[ "$1" == true ]]; then
kubectl apply -f "$2" >/dev/null 2>&1 || true
else
kubectl -n "$3" delete "secret/$4" --ignore-not-found >/dev/null 2>&1
fi
}
rollback() {
set +e
printf '\nROLLBACK: Keycloak 및 OIDC Secret 이전 상태 복원\n' >&2
[[ "$ai_member_added" == true ]] && \
request -X DELETE "$BASE/admin/realms/$REALM/users/$ai_user_uuid/groups/$ai_group_uuid" >/dev/null
[[ "$pg_member_added" == true ]] && \
request -X DELETE "$BASE/admin/realms/$REALM/users/$pg_user_uuid/groups/$pg_group_uuid" >/dev/null
for item in "ai $ai_client_uuid $ai_client_created" "pg $pg_client_uuid $pg_client_created"; do
set -- $item
if [[ "$3" == true ]]; then
request -X DELETE "$BASE/admin/realms/$REALM/clients/$2" >/dev/null
elif [[ -n "$2" ]]; then
request -X PUT -H 'Content-Type: application/json' \
--data-binary "@$temp/$1-client-before.json" \
"$BASE/admin/realms/$REALM/clients/$2" >/dev/null
fi
done
for item in "ai $ai_group_uuid $ai_group_created" "pg $pg_group_uuid $pg_group_created"; do
set -- $item
if [[ "$3" == true ]]; then
request -X DELETE "$BASE/admin/realms/$REALM/groups/$2" >/dev/null
elif [[ -n "$2" ]]; then
request -X PUT -H 'Content-Type: application/json' \
--data-binary "@$temp/$1-group-before.json" \
"$BASE/admin/realms/$REALM/groups/$2" >/dev/null
fi
done
restore_secret "$ai_secret_existed" "$temp/ai-secret.yaml" object-storage aistor-keycloak-oidc
restore_secret "$pg_secret_existed" "$temp/pg-secret.yaml" platform-admin pgadmin-keycloak-oidc
rollback_armed=false
printf 'ROLLBACK complete.\n' >&2
}
cleanup() {
rc=$?
trap - EXIT INT TERM
if (( rc != 0 )) && [[ "$rollback_armed" == true ]]; then rollback; fi
if [[ -n "$pf" ]] && kill -0 "$pf" 2>/dev/null; then
kill "$pf" 2>/dev/null || true
wait "$pf" 2>/dev/null || true
fi
case "$temp" in /tmp/keycloak-admin-oidc.*) rm -rf -- "$temp" ;; esac
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
rollback_armed=true
upsert_group platform-object-admins consoleAdmin "$temp/ai-group-before.json"
ai_group_uuid="$last_id"; ai_group_created="$last_created"
upsert_group platform-db-admins "" "$temp/pg-group-before.json"
pg_group_uuid="$last_id"; pg_group_created="$last_created"
upsert_client aistor-console "AIStor Console" https://storage-admin.learn.hyeonworks.com \
https://storage-admin.learn.hyeonworks.com/oauth_callback "$temp/ai-client-before.json"
ai_client_uuid="$last_id"; ai_client_created="$last_created"
upsert_mapper "$ai_client_uuid" aistor-policy-claim policy
upsert_client pgadmin pgAdmin https://db-admin.learn.hyeonworks.com \
https://db-admin.learn.hyeonworks.com/oauth2/authorize "$temp/pg-client-before.json"
pg_client_uuid="$last_id"; pg_client_created="$last_created"
upsert_mapper "$pg_client_uuid" pgadmin-groups group
for item in "object-storage aistor-keycloak-oidc aistor-console $ai_client_uuid ai" \
"platform-admin pgadmin-keycloak-oidc pgadmin $pg_client_uuid pg"; do
set -- $item
secret_file="$temp/$5-client-secret"
request -o "$response" "$BASE/admin/realms/$REALM/clients/$4/client-secret"
jq --exit-status --join-output --raw-output '.value | select(type == "string" and length >= 16)' "$response" >"$secret_file"
chmod 0600 "$secret_file"
kubectl -n "$1" create secret generic "$2" --from-literal="client-id=$3" \
--from-file="client-secret=$secret_file" --dry-run=client -o yaml | kubectl apply -f - >/dev/null
kubectl -n "$1" label "secret/$2" app.kubernetes.io/component=oidc-client \
app.kubernetes.io/part-of=platform app.kubernetes.io/managed-by=bootstrap-script \
--overwrite >/dev/null
done
if [[ -n "$object_admin" ]]; then
assign_member "$object_admin" "$ai_group_uuid"
ai_member_added="$last_member_added"; ai_user_uuid="$last_user_id"
fi
if [[ -n "$db_admin" ]]; then
assign_member "$db_admin" "$pg_group_uuid"
pg_member_added="$last_member_added"; pg_user_uuid="$last_user_id"
fi
for contract in "object-storage aistor-keycloak-oidc" "platform-admin pgadmin-keycloak-oidc"; do
set -- $contract
keys="$(kubectl -n "$1" get "secret/$2" \
-o go-template='{{range $key, $_ := .data}}{{$key}}{{"\n"}}{{end}}' | LC_ALL=C sort)"
[[ "$keys" == $'client-id\nclient-secret' ]] || fail "$1/$2 Secret 계약이 다릅니다"
done
request -o "$response" "$BASE/realms/$REALM/.well-known/openid-configuration"
jq -e --arg issuer "https://$KC_HOST/realms/$REALM" '.issuer == $issuer' "$response" >/dev/null || \
fail "OIDC discovery issuer가 다릅니다"
rollback_armed=false
printf 'KEYCLOAK ADMIN OIDC CONFIG SUCCESS\n'
printf 'Secret payload, 관리자 암호, token은 출력하지 않았습니다.\n'