forge/scripts/update-forge-fas-emails.py

344 lines
13 KiB
Python

#!/usr/bin/env python3
# ==============================================================================
# This script fixes placeholder email addresses created by the Pagure migrator.
# When users are migrated from Pagure to Forge, the migrator creates placeholder
# @fedoraproject.org email addresses (like username@fedoraproject.org) because
# it doesn't have access to users' real email addresses. This script replaces
# those placeholder addresses with the users' actual email addresses from FAS
# (Fedora Account System).
#
# The script ONLY processes users who already have @fedoraproject.org email
# addresses (the migrator placeholders). Users with real email addresses from
# other domains are left untouched - this is not a general email sync tool.
#
# The script handles three scenarios for @fedoraproject.org users:
# 1. User exists in FAS with email(s) - replaces placeholder with first FAS email
# 2. User exists in FAS but has no emails - sets to username+fasnotfound@fedoraproject.org
# 3. User not found in FAS - sets to username+fasnotfound@fedoraproject.org
#
# This ensures migrated users get proper email addresses for notifications,
# password resets, and other account-related communications.
# ==============================================================================
#
# Prerequisites and Setup:
#
# Step 1: Install Required Python Packages
#
# This script requires the following Python packages:
#
# dnf install python3-click python3-requests python3-fasjson-client
#
# Step 2: Obtain a Forge API Token
#
# You need an admin-level API token from your Forge instance:
# 1. Log into your Forge instance (staging or production)
# 2. Go to Settings -> Applications -> Generate New Token
# 3. Give it a descriptive name like "Email Sync Script"
# 4. Select the "admin" scope (required for user management)
# 5. Copy the generated token
#
# Step 3: Set up Kerberos Authentication for FAS
#
# The script uses FAS (Fedora Account System) which requires Kerberos auth:
#
# For staging:
# kinit your_username@STG.FEDORAPROJECT.ORG
#
# For production:
# kinit your_username@FEDORAPROJECT.ORG
#
# Step 4: Run the Script
#
# Basic usage examples:
#
# # Preview placeholder email fixes against staging (safe, no modifications)
# ./update-forge-fas-emails.py --dry-run --token YOUR_API_TOKEN
#
# # Actually fix placeholder emails on staging
# ./update-forge-fas-emails.py --token YOUR_API_TOKEN
#
# # Preview placeholder email fixes against production
# ./update-forge-fas-emails.py --production --dry-run --token YOUR_API_TOKEN
#
# # Actually fix placeholder emails on production (BE CAREFUL!)
# ./update-forge-fas-emails.py --production --token YOUR_API_TOKEN
#
# # Use environment variable for token (recommended for automation)
# export FORGE_TOKEN=your_api_token
# ./update-forge-fas-emails.py --production
#
# Step 5: Understanding the Output
#
# The script provides detailed output showing:
# - Which environment it's running against
# - Authentication status with FAS
# - Progress through all Forge users
# - Actions taken for each user (SKIP, NO CHANGE, UPDATED, FAILED, ERROR)
# - Summary statistics at the end
#
# Action meanings:
# - SKIP: User doesn't have @fedoraproject.org email (not a migrator placeholder)
# - NO CHANGE: User's placeholder email already matches their FAS email
# - UPDATED/WOULD UPDATE: Placeholder email was replaced with real FAS email
# - FAILED: API call to update email failed
# - ERROR: Unexpected error occurred (usually FAS connectivity issues)
#
# Safety Features:
# - Dry-run mode lets you preview changes before making them
# - Only processes @fedoraproject.org placeholder email addresses
# - Leaves users with real email addresses untouched
# - Comprehensive logging of all actions taken
# - Staging environment available for testing
#
# ==============================================================================
"""
Script to fix placeholder email addresses created by the Pagure migrator.
Replaces @fedoraproject.org placeholder emails with real FAS email addresses.
"""
import click
import requests
import sys
from fasjson_client import Client
def update_forge_email(forge_session, username, new_email, forge_url, dry_run=False):
"""Update a user's email in Forge via admin API"""
if dry_run:
return True, "Dry run - no changes made"
try:
response = forge_session.patch(
f'{forge_url}/api/v1/admin/users/{username}',
json={'active': True, 'email': new_email}
)
if response.status_code == 200:
return True, "Success"
else:
return False, f"HTTP {response.status_code}: {response.text[:50]}"
except Exception as e:
return False, str(e)[:50]
def get_config(production=False):
"""Get configuration based on environment"""
if production:
return {
'forge_url': "https://forge.fedoraproject.org",
'fasjson_url': "https://fasjson.fedoraproject.org",
'env_name': 'PRODUCTION'
}
else:
return {
'forge_url': "https://forge.stg.fedoraproject.org",
'fasjson_url': "https://fasjson.stg.fedoraproject.org",
'env_name': 'STAGING'
}
@click.command()
@click.option(
'--production',
is_flag=True,
help='Run against production environment (default: staging)'
)
@click.option(
'--dry-run',
is_flag=True,
help='Preview changes without making them'
)
@click.option(
'--token',
required=True,
help='Forge API token for authentication',
envvar='FORGE_TOKEN'
)
def main(production, dry_run, token):
"""Sync Forge user emails with FAS.
Examples:
\b
# Run against staging (default)
./update-forge-fas-emails.py --token YOUR_API_TOKEN
\b
# Run against production
./update-forge-fas-emails.py --production --token YOUR_API_TOKEN
\b
# Preview changes without making them
./update-forge-fas-emails.py --dry-run --token YOUR_API_TOKEN
\b
# Use environment variable for token
export FORGE_TOKEN=your_api_token
./update-forge-fas-emails.py --production
"""
config = get_config(production)
print("=" * 120)
print("Forge Email Sync from FAS")
print(f"Environment: {config['env_name']}")
print(f"Forge URL: {config['forge_url']}")
print(f"FAS URL: {config['fasjson_url']}")
if dry_run:
print("*** DRY RUN MODE - NO CHANGES WILL BE MADE ***")
print("=" * 120)
print()
# Setup Forge session
forge_session = requests.Session()
forge_session.headers.update({
'Authorization': f'token {token}',
'Content-Type': 'application/json'
})
# Setup FAS client
try:
fas_client = Client(config['fasjson_url'])
whoami = fas_client.whoami()
print(f"Authenticated with FAS as: {whoami.result.get('username', 'unknown')}")
except Exception as e:
print(f"Error: Cannot connect to FAS: {e}")
env_suffix = "FEDORAPROJECT.ORG" if production else "STG.FEDORAPROJECT.ORG"
print(f"Please run 'kinit your_username@{env_suffix}' first.\n")
return
# Get all Forge users
print("\nFetching users from Forge...")
forge_users = []
page = 1
limit = 50
while True:
response = forge_session.get(
f"{config['forge_url']}/api/v1/admin/users",
params={'page': page, 'limit': limit}
)
if response.status_code != 200:
print(f"Error fetching Forge users: {response.status_code}")
return
users_data = response.json()
if not users_data:
break
forge_users.extend(users_data)
page += 1
print(f"Found {len(forge_users)} total users on Forge")
print()
# Print table header
print("-" * 120)
print(f"{'Username':<25} {'Current Email':<35} {'Action':<15} {'New Email':<30}")
print("-" * 120)
# Stats counters
skipped_non_fedora = 0
skipped_already_processed = 0
skipped_error = 0
not_in_fas_updated = 0
updated_from_fas = 0
no_change_needed = 0
update_failed = 0
# Process each user
for user in forge_users:
username = user.get('login', 'N/A')
forge_email = user.get('email', 'N/A')
# Rule 1: Skip if not @fedoraproject.org email
if not forge_email.endswith('@fedoraproject.org'):
skipped_non_fedora += 1
continue
# Rule 2: Skip if already marked as fasnotfound (previously processed)
if '+fasnotfound@fedoraproject.org' in forge_email:
skipped_already_processed += 1
continue
# Try to get user from FAS
try:
user_response = fas_client.get_user(username=username)
user_data = user_response.result
emails = user_data.get('emails', [])
if emails:
# User found in FAS with email(s) - use first email
fas_email = emails[0]
if forge_email == fas_email:
# No change needed
no_change_needed += 1
print(f"{username:<25} {forge_email:<35} {'NO CHANGE':<15} (already {fas_email})")
else:
# Update to FAS email
success, msg = update_forge_email(forge_session, username, fas_email, config['forge_url'], dry_run)
if success:
updated_from_fas += 1
action = 'WOULD UPDATE' if dry_run else 'UPDATED'
print(f"{username:<25} {forge_email:<35} {action:<15} {fas_email}")
else:
update_failed += 1
print(f"{username:<25} {forge_email:<35} {'FAILED':<15} {msg}")
else:
# User found in FAS but no emails - this should never happen, log as error
skipped_error += 1
print(f"{username:<25} {forge_email:<35} {'ERROR':<15} User in FAS but has no emails")
except Exception as e:
error_str = str(e)
if 'not found' in error_str.lower() or '404' in error_str:
# User not found in FAS - set to username+fasnotfound@fedoraproject.org
new_email = f"{username}+fasnotfound@fedoraproject.org"
if forge_email == new_email:
no_change_needed += 1
print(f"{username:<25} {forge_email:<35} {'NO CHANGE':<15} (already {new_email})")
else:
success, msg = update_forge_email(forge_session, username, new_email, config['forge_url'], dry_run)
if success:
not_in_fas_updated += 1
action = 'WOULD UPDATE' if dry_run else 'UPDATED'
print(f"{username:<25} {forge_email:<35} {action:<15} {new_email} (not in FAS)")
else:
update_failed += 1
print(f"{username:<25} {forge_email:<35} {'FAILED':<15} {msg}")
else:
# Rule 2: FAS error - don't do anything
skipped_error += 1
print(f"{username:<25} {forge_email:<35} {'ERROR':<15} {str(e)[:30]}")
# Print summary
print("-" * 120)
print()
print("=" * 120)
print("SUMMARY")
print("=" * 120)
print(f"Total Forge users: {len(forge_users)}")
print()
print(f"Skipped (non-@fedoraproject.org email): {skipped_non_fedora}")
print(f"Skipped (already processed +fasnotfound): {skipped_already_processed}")
print(f"Skipped (FAS error): {skipped_error}")
print()
print(f"No change needed: {no_change_needed}")
changes_verb = "Would be made" if dry_run else "Made"
print(f"Updated from FAS: {updated_from_fas}")
print(f"Updated to +fasnotfound (not in FAS): {not_in_fas_updated}")
print(f"Update failed: {update_failed}")
print()
total_processed = len(forge_users) - skipped_non_fedora - skipped_already_processed - skipped_error
print(f"Total @fedoraproject.org users processed: {total_processed}")
print(f"Total changes {changes_verb.lower()}: {updated_from_fas + not_in_fas_updated}")
if dry_run:
print()
print("*** DRY RUN MODE - Use --production flag without --dry-run to make actual changes ***")
print("=" * 120)
if __name__ == '__main__':
main()