#!/bin/sh set -eu app_ini="${GITEA_APP_INI:-/data/gitea/conf/app.ini}" work_dir="${GITEA_WORK_DIR:-/data/gitea}" api_url="${GITEA_API_URL:-http://gitea.gitea.svc.cluster.local:3000/api/v1}" public_url="${GITEA_PUBLIC_URL:-https://scm.bstein.dev}" vault_addr="${VAULT_ADDR:-http://vault.vault.svc.cluster.local:8200}" vault_role="${VAULT_ROLE:-gitea}" service_account_token=/var/run/secrets/kubernetes.io/serviceaccount/token reconciler_user=atlas-reconciler reconciler_email=atlas-reconciler@bstein.dev hermes_user=hermes-automation hermes_email=hermes-automation@bstein.dev organization=titan owner_user=bstein source_owner="${GITEA_SOURCE_OWNER:-bstein}" managed_repositories="${GITEA_MANAGED_REPOSITORIES:-hermes-code-demo cassandra soteria pegasus metis ananke ariadne typhon atlas-iac}" contributor_team=Contributors contributor_units='["repo.actions","repo.packages","repo.code","repo.issues","repo.wiki","repo.pulls","repo.releases","repo.projects"]' protected_reviewer=bstein die() { echo "Gitea Atlas identity bootstrap failed: $*" >&2 exit 1 } gitea_admin() { /usr/local/bin/gitea --work-path "${work_dir}" --config "${app_ini}" admin "$@" } wait_for_gitea() { attempt=0 while [ "${attempt}" -lt 120 ]; do if [ -r "${app_ini}" ] && curl --fail --silent --show-error "${api_url}/version" >/dev/null 2>&1; then return 0 fi attempt=$((attempt + 1)) sleep 2 done die "Gitea did not become ready" } user_exists() { wanted_user=$1 gitea_admin user list 2>/dev/null | awk 'NR > 1 { print $2 }' | grep -Fxq "${wanted_user}" } ensure_user() { username=$1 email=$2 admin_flag=$3 if user_exists "${username}"; then return 0 fi if [ "${admin_flag}" = true ]; then gitea_admin user create \ --username "${username}" \ --email "${email}" \ --random-password \ --must-change-password=false \ --admin >/dev/null else gitea_admin user create \ --username "${username}" \ --email "${email}" \ --random-password \ --must-change-password=false >/dev/null fi } generate_token() { username=$1 token_name="atlas-bootstrap-$(date +%s)-${username}" gitea_admin user generate-access-token \ --username "${username}" \ --token-name "${token_name}" \ --scopes all \ --raw 2>/dev/null } token_is_valid() { username=$1 token=$2 [ -n "${token}" ] || return 1 response_file=$(mktemp) status=$(curl --silent --show-error \ --output "${response_file}" \ --write-out '%{http_code}' \ --header "Authorization: token ${token}" \ "${api_url}/user") if [ "${status}" = 200 ] && grep -Eq "\"login\"[[:space:]]*:[[:space:]]*\"${username}\"" "${response_file}"; then rm -f "${response_file}" return 0 fi rm -f "${response_file}" return 1 } api_request() { method=$1 path=$2 data=${3:-} response_file=$4 if [ -n "${data}" ]; then status=$(curl --silent --show-error \ --output "${response_file}" \ --write-out '%{http_code}' \ --request "${method}" \ --header "Authorization: token ${reconciler_token}" \ --header 'Content-Type: application/json' \ --data "${data}" \ "${api_url}${path}") else status=$(curl --silent --show-error \ --output "${response_file}" \ --write-out '%{http_code}' \ --request "${method}" \ --header "Authorization: token ${reconciler_token}" \ "${api_url}${path}") fi printf '%s' "${status}" } expect_status() { actual=$1 expected=$2 description=$3 [ "${actual}" = "${expected}" ] || die "${description} returned HTTP ${actual}" } find_team_id() { team_name=$1 teams_file=$(mktemp) status=$(api_request GET "/orgs/${organization}/teams" '' "${teams_file}") expect_status "${status}" 200 "list organization teams" team_id=$(sed 's/},{/}\ {/g' "${teams_file}" | grep "\"name\":\"${team_name}\"" | head -n 1 | grep -o '"id":[0-9]*' | head -n 1 | cut -d: -f2 || true) rm -f "${teams_file}" [ -n "${team_id}" ] || die "team ${team_name} was not found" printf '%s' "${team_id}" } vault_login() { [ -r "${service_account_token}" ] || die "Kubernetes service account token is unavailable" jwt=$(cat "${service_account_token}") login_file=$(mktemp) status=$(curl --silent --show-error \ --output "${login_file}" \ --write-out '%{http_code}' \ --request POST \ --header 'Content-Type: application/json' \ --data "{\"role\":\"${vault_role}\",\"jwt\":\"${jwt}\"}" \ "${vault_addr}/v1/auth/kubernetes/login") expect_status "${status}" 200 "Vault Kubernetes login" vault_token=$(sed -n 's/.*"client_token":"\([^"]*\)".*/\1/p' "${login_file}") rm -f "${login_file}" [ -n "${vault_token}" ] || die "Vault login did not return a client token" } vault_read_field() { path=$1 field=$2 response_file=$(mktemp) status=$(curl --silent --show-error \ --output "${response_file}" \ --write-out '%{http_code}' \ --header "X-Vault-Token: ${vault_token}" \ "${vault_addr}/v1/kv/data/atlas/${path}") case "${status}" in 200) value=$(sed -n "s/.*\"${field}\":\"\([^\"]*\)\".*/\1/p" "${response_file}") ;; 404) value= ;; *) rm -f "${response_file}" die "read Vault path ${path} returned HTTP ${status}" ;; esac rm -f "${response_file}" printf '%s' "${value}" } vault_write() { path=$1 payload=$2 response_file=$(mktemp) status=$(curl --silent --show-error \ --output "${response_file}" \ --write-out '%{http_code}' \ --request POST \ --header "X-Vault-Token: ${vault_token}" \ --header 'Content-Type: application/json' \ --data "${payload}" \ "${vault_addr}/v1/kv/data/atlas/${path}") rm -f "${response_file}" expect_status "${status}" 200 "write Vault path ${path}" } transfer_managed_repositories() { transfer_tmp_dir=$(mktemp -d) ready_file="${transfer_tmp_dir}/ready" source_file="${transfer_tmp_dir}/source" target_file="${transfer_tmp_dir}/target" response_file="${transfer_tmp_dir}/response" : >"${ready_file}" cleanup_transfer_tmp() { rm -rf "${transfer_tmp_dir}" } trap cleanup_transfer_tmp EXIT trap 'exit 1' HUP INT TERM # Validate the complete set before changing ownership so a typo or collision # cannot leave the managed repositories split between two owners. for repository in ${managed_repositories}; do source_status=$(api_request GET "/repos/${source_owner}/${repository}" '' "${source_file}") target_status=$(api_request GET "/repos/${organization}/${repository}" '' "${target_file}") case "${source_status}:${target_status}" in 200:404) printf '%s\n' "${repository}" >>"${ready_file}" ;; 301:200|302:200|307:200|308:200|404:200) # Gitea may redirect the old API path after a native transfer. ;; 200:200) die "repository ${repository} exists under both ${source_owner} and ${organization}" ;; 404:404) die "repository ${repository} does not exist under ${source_owner} or ${organization}" ;; *) die "repository ${repository} preflight returned source HTTP ${source_status}, target HTTP ${target_status}" ;; esac done while IFS= read -r repository; do [ -n "${repository}" ] || continue status=$(api_request POST "/repos/${source_owner}/${repository}/transfer" "{\"new_owner\":\"${organization}\"}" "${response_file}") case "${status}" in 201|202) ;; *) die "transfer repository ${repository} returned HTTP ${status}" ;; esac attempt=0 transferred=false while [ "${attempt}" -lt 30 ]; do target_status=$(api_request GET "/repos/${organization}/${repository}" '' "${response_file}") if [ "${target_status}" = 200 ]; then transferred=true break fi attempt=$((attempt + 1)) sleep 2 done if [ "${transferred}" != true ]; then die "repository ${repository} did not appear under ${organization} after transfer" fi done <"${ready_file}" cleanup_transfer_tmp trap - EXIT HUP INT TERM } ensure_default_branch_protection() { repository=$1 repo_file=$(mktemp) protections_file=$(mktemp) response_file=$(mktemp) status=$(api_request GET "/repos/${organization}/${repository}" '' "${repo_file}") expect_status "${status}" 200 "read ${repository} metadata for branch protection" default_branch=$(sed -n 's/.*"default_branch":"\([^"]*\)".*/\1/p' "${repo_file}") case "${default_branch}" in main|master) ;; *) die "${repository} has unsupported default branch ${default_branch:-missing}" ;; esac status=$(api_request GET "/repos/${organization}/${repository}/branch_protections" '' "${protections_file}") expect_status "${status}" 200 "list ${repository} branch protections" if ! protection_state=$( /opt/python/bin/python3 /opt/bootstrap/gitea_branch_protection_check.py \ "${protections_file}" "${default_branch}" "${protected_reviewer}" ); then die "${repository} ${default_branch} protection differs from the human-review policy" fi if [ "${protection_state}" = ABSENT ]; then payload="{\"rule_name\":\"${default_branch}\",\"enable_push\":true,\"enable_push_whitelist\":true,\"push_whitelist_usernames\":[\"${protected_reviewer}\"],\"push_whitelist_teams\":[],\"push_whitelist_deploy_keys\":false,\"enable_force_push\":false,\"enable_force_push_allowlist\":false,\"force_push_allowlist_usernames\":[],\"force_push_allowlist_teams\":[],\"force_push_allowlist_deploy_keys\":false,\"enable_merge_whitelist\":true,\"merge_whitelist_usernames\":[\"${protected_reviewer}\"],\"merge_whitelist_teams\":[],\"enable_approvals_whitelist\":true,\"approvals_whitelist_username\":[\"${protected_reviewer}\"],\"approvals_whitelist_teams\":[],\"required_approvals\":1,\"block_on_rejected_reviews\":true,\"block_on_outdated_branch\":true,\"dismiss_stale_approvals\":true,\"block_admin_merge_override\":true}" status=$(api_request POST "/repos/${organization}/${repository}/branch_protections" "${payload}" "${response_file}") expect_status "${status}" 201 "protect ${repository} ${default_branch}" status=$(api_request GET "/repos/${organization}/${repository}/branch_protections" '' "${protections_file}") expect_status "${status}" 200 "read back ${repository} branch protections" protection_state=$( /opt/python/bin/python3 /opt/bootstrap/gitea_branch_protection_check.py \ "${protections_file}" "${default_branch}" "${protected_reviewer}" ) || die "${repository} ${default_branch} protection differs from the human-review policy" fi [ "${protection_state}" = PRESENT ] || \ die "${repository} ${default_branch} protection did not become effective" rm -f "${repo_file}" "${protections_file}" "${response_file}" } wait_for_gitea ensure_user "${reconciler_user}" "${reconciler_email}" true ensure_user "${hermes_user}" "${hermes_email}" false vault_login reconciler_token=$(vault_read_field gitea/atlas-reconciler token) if ! token_is_valid "${reconciler_user}" "${reconciler_token}"; then reconciler_token=$(generate_token "${reconciler_user}") fi hermes_token=$(vault_read_field hermes/developer-gitea token) if ! token_is_valid "${hermes_user}" "${hermes_token}"; then hermes_token=$(generate_token "${hermes_user}") fi [ -n "${reconciler_token}" ] || die "reconciler token generation failed" [ -n "${hermes_token}" ] || die "Hermes token generation failed" response_file=$(mktemp) status=$(api_request POST /orgs "{\"username\":\"${organization}\",\"full_name\":\"Titan\",\"description\":\"Titan repositories for the Atlas platform\",\"visibility\":\"public\",\"repo_admin_change_team_access\":true}" "${response_file}") rm -f "${response_file}" case "${status}" in 201|422) ;; *) die "create organization returned HTTP ${status}" ;; esac owners_id=$(find_team_id Owners) response_file=$(mktemp) status=$(api_request PUT "/teams/${owners_id}/members/${owner_user}" '' "${response_file}") rm -f "${response_file}" expect_status "${status}" 204 "add ${owner_user} to Titan owners" response_file=$(mktemp) status=$(api_request POST "/orgs/${organization}/teams" "{\"name\":\"${contributor_team}\",\"description\":\"Titan automation contributors\",\"permission\":\"write\",\"includes_all_repositories\":true,\"can_create_org_repo\":false,\"units\":${contributor_units}}" "${response_file}") rm -f "${response_file}" case "${status}" in 201|422) ;; *) die "create contributor team returned HTTP ${status}" ;; esac contributors_id=$(find_team_id "${contributor_team}") response_file=$(mktemp) status=$(api_request PUT "/teams/${contributors_id}/members/${hermes_user}" '' "${response_file}") rm -f "${response_file}" expect_status "${status}" 204 "add Hermes to Titan contributors" transfer_managed_repositories for repository in ${managed_repositories}; do ensure_default_branch_protection "${repository}" done vault_write gitea/atlas-reconciler "{\"data\":{\"username\":\"${reconciler_user}\",\"token\":\"${reconciler_token}\",\"base_url\":\"${public_url}\"}}" vault_write hermes/developer-gitea "{\"data\":{\"username\":\"${hermes_user}\",\"email\":\"${hermes_email}\",\"token\":\"${hermes_token}\",\"base_url\":\"${public_url}\"}}" echo "Titan organization, managed repositories, and Hermes contributor identity are ready"