370 lines
13 KiB
Bash
370 lines
13 KiB
Bash
#!/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"
|