296 lines
14 KiB
Python

#!/usr/bin/env python3
"""
CLI tool to set up profiles from a YAML policy file.
"""
import argparse
import json
import logging
import os
import subprocess
import sys
from typing import List
try:
import yaml
except ImportError:
print("PyYAML is not installed. Please install it with: pip install PyYAML", file=sys.stderr)
yaml = None
try:
from dotenv import load_dotenv
except ImportError:
load_dotenv = None
logger = logging.getLogger(__name__)
def run_command(cmd: List[str], capture: bool = False):
"""Runs a command and raises an exception on failure."""
logger.debug(f"Running command: {' '.join(cmd)}")
# check=True will raise CalledProcessError on non-zero exit codes
result = subprocess.run(
cmd,
capture_output=capture,
text=True,
check=True
)
return result
def add_setup_profiles_parser(subparsers):
"""Adds the parser for the 'setup-profiles' command."""
parser = subparsers.add_parser(
'setup-profiles',
description="Set up profiles for a simulation or test run based on a policy file.",
formatter_class=argparse.RawTextHelpFormatter,
help="Set up profiles from a policy file."
)
parser.add_argument('--policy', '--policy-file', dest='policy_file', required=True, help="Path to the YAML profile setup policy file.")
parser.add_argument('--env', help="Override the environment name from the policy file. For multi-setup files, this will override the 'env' for ALL setups being run.")
parser.add_argument('--env-file', help="Override the env_file setting in the policy.")
parser.add_argument('--auth-only', action='store_true', help='In a multi-setup policy file, run only the auth_profile_setup.')
parser.add_argument('--download-only', action='store_true', help='In a multi-setup policy file, run only the download_profile_setup.')
parser.add_argument('--redis-host', default=None, help='Redis host. Overrides policy and .env file.')
parser.add_argument('--redis-port', type=int, default=None, help='Redis port. Overrides policy and .env file.')
parser.add_argument('--redis-password', default=None, help='Redis password. Overrides policy and .env file.')
parser.add_argument('--preserve-profiles', action='store_true', help="Do not clean up existing profiles; create only what is missing.")
parser.add_argument('--cleanup-prefix', action='append', help="Prefix of profiles to delete before setup. Can be specified multiple times. Overrides policy-based cleanup.")
parser.add_argument('--cleanup-all', action='store_true', help="(Destructive) Delete ALL data for the environment (profiles, proxies, counters) before setup. Overrides all other cleanup options.")
parser.add_argument('--reset-global-counters', action='store_true', help="Reset global counters like 'failed_lock_attempts'.")
parser.add_argument('--verbose', action='store_true', help="Enable verbose logging.")
return parser
def _run_setup_for_env(profile_setup: dict, common_args: list, args: argparse.Namespace) -> int:
"""Runs the profile setup logic for a given configuration block."""
if args.cleanup_all:
logger.info("--- (DESTRUCTIVE) Cleaning up all data for the environment via --cleanup-all ---")
try:
cleanup_cmd = ['bin/ytops-client', 'profile', 'delete-all', '--confirm'] + common_args
run_command(cleanup_cmd)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to clean up all data for the environment: {e}")
return 1 # Stop if cleanup fails
# Disable other cleanup logic
profile_setup['cleanup_before_run'] = False
args.cleanup_prefix = None
# If --cleanup-prefix is provided, it takes precedence over policy settings
if args.cleanup_prefix:
logger.info("--- Cleaning up profiles based on --cleanup-prefix ---")
for prefix in args.cleanup_prefix:
try:
list_cmd = ['bin/ytops-client', 'profile', 'list', '--format', 'json'] + common_args
result = run_command(list_cmd, capture=True)
profiles_to_delete = [p for p in json.loads(result.stdout) if p['name'].startswith(prefix)]
if not profiles_to_delete:
logger.info(f"No profiles with prefix '{prefix}' found to delete.")
continue
logger.info(f"Found {len(profiles_to_delete)} profiles with prefix '{prefix}' to delete.")
for p in profiles_to_delete:
delete_cmd = ['bin/ytops-client', 'profile', 'delete', p['name'], '--confirm'] + common_args
run_command(delete_cmd)
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
logger.warning(f"Could not list or parse existing profiles with prefix '{prefix}' for cleanup. Error: {e}")
# Disable policy-based cleanup as we've handled it via CLI
profile_setup['cleanup_before_run'] = False
if args.preserve_profiles:
if profile_setup.get('cleanup_before_run'):
logger.info("--preserve-profiles is set, overriding 'cleanup_before_run: true' from policy.")
profile_setup['cleanup_before_run'] = False
if args.reset_global_counters:
logger.info("--- Resetting global counters ---")
try:
reset_cmd = ['bin/ytops-client', 'profile', 'reset-global-counters'] + common_args
run_command(reset_cmd)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to reset global counters: {e}")
if profile_setup.get('cleanup_before_run'):
logger.info("--- Cleaning up old profiles ---")
for pool in profile_setup.get('pools', []):
prefix = pool.get('prefix')
if prefix:
try:
list_cmd = ['bin/ytops-client', 'profile', 'list', '--format', 'json'] + common_args
result = run_command(list_cmd, capture=True)
profiles = [p for p in json.loads(result.stdout) if p['name'].startswith(prefix)]
if not profiles:
logger.info(f"No profiles with prefix '{prefix}' found to delete.")
continue
logger.info(f"Found {len(profiles)} profiles with prefix '{prefix}' to delete.")
for p in profiles:
delete_cmd = ['bin/ytops-client', 'profile', 'delete', p['name'], '--confirm'] + common_args
run_command(delete_cmd)
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
logger.warning(f"Could not list existing profiles with prefix '{prefix}' for cleanup. Assuming none exist. Error: {e}")
existing_profiles = set()
if not profile_setup.get('cleanup_before_run'):
logger.info("--- Checking for existing profiles ---")
try:
list_cmd = ['bin/ytops-client', 'profile', 'list', '--format', 'json'] + common_args
result = run_command(list_cmd, capture=True)
profiles_data = json.loads(result.stdout)
existing_profiles = {p['name'] for p in profiles_data}
logger.info(f"Found {len(existing_profiles)} existing profiles.")
except (subprocess.CalledProcessError, json.JSONDecodeError) as e:
logger.error(f"Failed to list existing profiles. Will attempt to create all profiles. Error: {e}")
logger.info("--- Creating new profiles (if needed) ---")
for pool in profile_setup.get('pools', []):
prefix = pool.get('prefix')
proxy = pool.get('proxy')
count = pool.get('count', 0)
start_in_rest_minutes = pool.get('start_in_rest_for_minutes')
profiles_in_pool_created = 0
# Get a list of all profiles that should exist for this pool
profile_names_in_pool = [f"{prefix}_{i}" for i in range(count)]
for profile_name in profile_names_in_pool:
if profile_name in existing_profiles:
logger.debug(f"Profile '{profile_name}' already exists, preserving.")
continue
try:
create_cmd = ['bin/ytops-client', 'profile', 'create', profile_name, proxy] + common_args
run_command(create_cmd)
profiles_in_pool_created += 1
except subprocess.CalledProcessError as e:
logger.error(f"Failed to create profile '{profile_name}': {e}")
if profiles_in_pool_created > 0:
logger.info(f"Created {profiles_in_pool_created} new profile(s) for pool '{prefix}'.")
elif count > 0:
logger.info(f"No new profiles needed for pool '{prefix}'. All {count} profile(s) already exist.")
# If requested, put the proxy for this pool into a RESTING state.
# This is done even for existing profiles when --preserve-profiles is used.
if start_in_rest_minutes and proxy:
logger.info(f"Setting proxy '{proxy}' for pool '{prefix}' to start in RESTING state for {start_in_rest_minutes} minutes.")
try:
# The 'set-proxy-state' command takes '--duration-minutes'
set_state_cmd = ['bin/ytops-client', 'profile', 'set-proxy-state', proxy, 'RESTING',
'--duration-minutes', str(start_in_rest_minutes)] + common_args
run_command(set_state_cmd)
except subprocess.CalledProcessError as e:
logger.error(f"Failed to set initial REST state for proxy '{proxy}': {e}")
return 0
def main_setup_profiles(args):
"""Main logic for the 'setup-profiles' command."""
if not yaml: return 1
if args.verbose:
logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger.setLevel(logging.DEBUG)
else:
if not logging.getLogger().handlers:
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger.setLevel(logging.INFO)
try:
with open(args.policy_file, 'r', encoding='utf-8') as f:
policy = yaml.safe_load(f) or {}
except (IOError, yaml.YAMLError) as e:
logger.error(f"Failed to load or parse policy file {args.policy_file}: {e}")
return 1
# --- Expand common_pools if defined ---
common_pools_def = policy.get('common_pools')
if common_pools_def:
logger.info("Found 'common_pools' definition. Expanding into setup blocks.")
expanded_pools = []
for pool_def in common_pools_def:
# Handle both 'prefix' (string) and 'prefixes' (list) for flexibility
prefixes = pool_def.get('prefixes', [])
if 'prefix' in pool_def and pool_def['prefix'] not in prefixes:
prefixes.append(pool_def['prefix'])
if not prefixes:
logger.warning(f"A pool in 'common_pools' is missing a 'prefix' or 'prefixes' key. Skipping: {pool_def}")
continue
for p in prefixes:
new_pool = pool_def.copy()
new_pool['prefix'] = p
if 'prefixes' in new_pool: del new_pool['prefixes']
expanded_pools.append(new_pool)
for setup_key in ['auth_profile_setup', 'download_profile_setup']:
if setup_key in policy and policy[setup_key].get('use_common_pools'):
logger.debug(f"Applying {len(expanded_pools)} common pool definitions to '{setup_key}'.")
policy[setup_key]['pools'] = expanded_pools
sim_params = policy.get('simulation_parameters', {})
setups_to_run = []
if not args.download_only and 'auth_profile_setup' in policy:
setups_to_run.append(('Auth', policy['auth_profile_setup']))
if not args.auth_only and 'download_profile_setup' in policy:
setups_to_run.append(('Download', policy['download_profile_setup']))
# Backward compatibility for old single-block format
if not setups_to_run and 'profile_setup' in policy:
legacy_config = policy['profile_setup']
# Synthesize the env from the global section for the legacy block
legacy_config['env'] = args.env or sim_params.get('env')
setups_to_run.append(('Legacy', legacy_config))
if not setups_to_run:
logger.error("No 'auth_profile_setup', 'download_profile_setup', or legacy 'profile_setup' block found in policy file.")
return 1
env_file = args.env_file or sim_params.get('env_file')
if load_dotenv:
if load_dotenv(env_file):
print(f"Loaded environment variables from {env_file or '.env file'}", file=sys.stderr)
elif args.env_file and not os.path.exists(args.env_file):
print(f"Error: The specified env_file was not found: {args.env_file}", file=sys.stderr)
return 1
base_common_args = []
if env_file: base_common_args.extend(['--env-file', env_file])
redis_host = args.redis_host or os.getenv('REDIS_HOST') or os.getenv('MASTER_HOST_IP') or sim_params.get('redis_host')
if redis_host: base_common_args.extend(['--redis-host', redis_host])
redis_port = args.redis_port
if redis_port is None:
redis_port_env = os.getenv('REDIS_PORT')
redis_port = int(redis_port_env) if redis_port_env and redis_port_env.isdigit() else sim_params.get('redis_port')
if redis_port: base_common_args.extend(['--redis-port', str(redis_port)])
redis_password = args.redis_password or os.getenv('REDIS_PASSWORD') or sim_params.get('redis_password')
if redis_password: base_common_args.extend(['--redis-password', redis_password])
if args.verbose: base_common_args.append('--verbose')
for setup_name, setup_config in setups_to_run:
logger.info(f"--- Running setup for {setup_name} simulation ---")
effective_env = args.env or setup_config.get('env')
if not effective_env:
logger.error(f"Could not determine environment for '{setup_name}' setup. Please specify 'env' in the policy block or via --env.")
return 1
env_common_args = base_common_args + ['--env', effective_env]
if _run_setup_for_env(setup_config, env_common_args, args) != 0:
return 1
logger.info("\n--- All profile setups complete. ---")
logger.info("You can now run the policy enforcer to manage the profiles:")
logger.info("e.g., bin/ytops-client policy-enforcer --policy-file policies/8_unified_simulation_enforcer.yaml --live")
return 0