"""CLI utility for managing Split.io feature flags.""" import argparse import json import re import sys from typing import List from config import ( FORBIDDEN_SUBSTRINGS, HARNESS_API_KEY, setup_logger, SPLITIO_WORKSPACE_ID ) import pandas as pd from splitio_client import SplitIOClient from tabulate import tabulate logger = setup_logger(__name__) def validate_flag_name(name: str) -> None: """ Validate the feature flag name. This ensures the name does not contain forbidden substrings and follows snake_case naming convention. Args: name (str): The name of the feature flag. Raises: ValueError: If the name violates the rules. """ for keyword in FORBIDDEN_SUBSTRINGS: if keyword.lower() in name.lower(): raise ValueError(f"Feature flag name cannot contain '{keyword}'.") snake_case_pattern = r'^[a-z0-9]+(?:_[a-z0-9]+)*$' if not re.match(snake_case_pattern, name): raise ValueError( 'Feature flag name must follow snake_case (e.g., my_feature_name). ' 'Only lowercase letters, numbers, and underscores are allowed.' ) def create_flag( name: str, owners: List[str], teams: List[str], jira: str, description: str, traffic_type: str, splitio: SplitIOClient ) -> None: """ Create a feature flag in Split.io with validation, creation, and patching. Args: name (str): Name of the feature flag. owners (List[str]): List of owner identifiers (e.g., emails). teams (List[str]): List of team names to be used as tags. jira (str): Jira ticket reference (e.g., ABC-123). description (str): Optional description of the flag. traffic_type (str): Traffic type for the flag (default is 'user'). splitio (SplitIOClient): Authenticated Split.io client instance. Raises: ValueError: On input validation issues or if the flag already exists. RuntimeError: On API failures. """ validate_flag_name(name) if not owners or not teams or not jira: raise ValueError('Missing required fields: owners, teams, or jira.') if not re.match(r'^[A-Z]+-\d+$', jira): raise ValueError('Invalid Jira ticket format (e.g., ABC-123).') resolved_owners = [splitio.resolve_owner(owner) for owner in owners] full_description = f'[{jira}] {description}'.strip() flag_data = { 'name': name, 'description': full_description, 'owners': resolved_owners, 'tags': teams, 'trafficType': traffic_type, } logger.debug('Feature flag definition prepared:') logger.debug(json.dumps(flag_data, indent=2)) # Fetch traffic type ID traffic_types: List[dict] = splitio.get_traffic_types() traffic_type_id: str | None = next( (t['id'] for t in traffic_types if t['name'] == traffic_type), None ) if not traffic_type_id: raise ValueError(f"Traffic type '{traffic_type}' not found.") try: splitio.create_feature_flag(name, traffic_type_id, flag_data) logger.info(f"Created feature flag '{name}'.") except Exception as e: if '409' in str(e): raise ValueError(f"Feature flag '{name}' already exists.") raise RuntimeError(f"Failed to create FF '{name}': {e}") patch_ops: List[dict] = [] for i, tag in enumerate(flag_data['tags']): patch_ops.append({'op': 'add', 'path': f'/tags/{i}', 'value': {'name': tag}}) if flag_data['description']: patch_ops.append({ 'op': 'replace', 'path': '/description', 'value': flag_data['description'] }) try: splitio.patch_feature_flag(name, patch_ops) logger.info(f"Patched tags/description status for '{name}'.") except Exception as e: raise RuntimeError(f"Failed to patch flag '{name}': {e}") definition_data = { 'treatments': [ { 'name': 'on', 'description': '' }, { 'name': 'off', 'description': '' } ], 'defaultTreatment': 'off', 'baselineTreatment': 'off', 'trafficAllocation': 100, 'rules': [], 'defaultRule': [ { 'treatment': 'off', 'size': 100 } ], 'comment': "Initial environment definition setup with default treatment set to 'off'" } environments = splitio.get_environments() for env in environments: try: splitio.create_flag_definition(name, env['id'], definition_data) logger.info(f"Created definition for '{name}' in environment '{env['name']}'.") except Exception as e: raise RuntimeError(f"Failed to create definition in env '{env['name']}': {e}") def collect_feature_flag_data( splitio: SplitIOClient, filter_team: str | None = None ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]: """ Collect and transform feature flag, user, and group data into DataFrames. Args: splitio (SplitIOClient): Authenticated Split.io client. filter_team (str, optional): Filter flags by team tag. Returns: tuple: (flags_df, users_df, groups_df) """ flags = splitio.get_all_feature_flags() users = splitio.get_all_users() groups = splitio.get_all_groups() user_map = {user['uuid']: user['name'] for user in users} group_map = {group['identifier']: group['name'] for group in groups} flag_rows = [] for flag in flags: tags = [tag['name'] for tag in (flag.get('tags') or []) if tag and 'name' in tag] if filter_team and filter_team not in tags: continue owners = flag.get('owners', []) owner_user_names = [ user_map.get(owner['id'], 'Unknown') for owner in owners if owner and owner['type'] == 'user' and 'id' in owner ] owner_group_names = [ group_map.get(owner['id'], 'Unknown') for owner in owners if owner and owner['type'] == 'group' and 'id' in owner ] creation_time = '' if flag.get('creationTime'): try: creation_time = pd.to_datetime(flag['creationTime'], unit='ms').strftime('%Y-%m-%d %H:%M:%S') except Exception: creation_time = flag['creationTime'] flag_rows.append({ 'Name': flag.get('name'), 'Description': flag.get('description'), 'Traffic Type': flag.get('trafficType', {}).get('name'), 'Rollout Status': flag.get('rolloutStatus', {}).get('name'), 'Creation Time': creation_time, 'Owners': ', '.join(owner_user_names), 'Groups': ', '.join(owner_group_names), 'Tags': ', '.join(tags), }) flags_df = pd.DataFrame(flag_rows) users_df = pd.DataFrame(users)[ ['name', 'email', 'uuid', 'locked', 'disabled', 'externallyManaged', 'twoFactorAuthenticationEnabled'] ] groups_df = pd.DataFrame(groups)[ ['name', 'accountIdentifier', 'identifier', 'externallyManaged', 'description', 'harnessManaged', 'ssoLinked'] ] return flags_df, users_df, groups_df def export_audit_data( flags_df: pd.DataFrame, users_df: pd.DataFrame, groups_df: pd.DataFrame, output: str = 'excel' ) -> None: """ Export audit data to Excel, JSON, or pretty table. Args: flags_df (pd.DataFrame): Feature flags data. users_df (pd.DataFrame): Users data. groups_df (pd.DataFrame): Groups data. output (str): Output format: 'excel', 'json', or 'table'. """ if output == 'excel': output_file = 'artifacts/feature_flags_audit.xlsx' with pd.ExcelWriter(output_file, engine='openpyxl', mode='w') as writer: flags_df.to_excel(writer, sheet_name='Feature Flags', index=False) users_df.to_excel(writer, sheet_name='Users', index=False) groups_df.to_excel(writer, sheet_name='Groups', index=False) logger.info(f'✅ Exported audit to {output_file}') elif output == 'json': result = { 'feature_flags': flags_df.to_dict(orient='records'), 'users': users_df.to_dict(orient='records'), 'groups': groups_df.to_dict(orient='records'), } output_file = 'artifacts/feature_flags_audit.json' with open(output_file, 'w', encoding='utf-8') as f: json.dump(result, f, indent=4) logger.info(f'✅ Exported audit to {output_file}') elif output == 'table': print('\n=== Feature Flags ===') print(tabulate(flags_df.values.tolist(), headers=list(flags_df.columns), tablefmt='grid')) print('\n=== Users ===') print(tabulate(users_df.values.tolist(), headers=list(users_df.columns), tablefmt='grid')) print('\n=== Groups ===') print(tabulate(groups_df.values.tolist(), headers=list(groups_df.columns), tablefmt='grid')) else: raise ValueError(f'Unknown output format: {output}') def audit_feature_flags(splitio: SplitIOClient, output: str = 'excel', filter_team: str | None = None) -> None: """ Audit all feature flags, users, and groups, exporting the data. Args: splitio (SplitIOClient): Authenticated Split.io client instance. output (str): Output format: 'excel', 'json', or 'table'. filter_team (str, optional): Filter flags by team tag. Raises: RuntimeError: On API failures. """ try: flags_df, users_df, groups_df = collect_feature_flag_data(splitio, filter_team) export_audit_data(flags_df, users_df, groups_df, output) except Exception as e: raise RuntimeError(f'Failed to audit feature flags: {e}') from e def backfill_tags_from_excel( splitio: SplitIOClient, excel_file: str, only_team: str | None = None ) -> None: """ Backfill feature flag tags from an Excel file containing flag_name and team_owner. Args: splitio (SplitIOClient): Authenticated Split.io client. excel_file (str): Path to the Excel file containing columns 'Name' and 'Team Owner'. only_team (str | None): If provided, only backfill rows where 'Team Owner' matches. Raises: ValueError: If required columns are missing. """ try: df = pd.read_excel(excel_file, sheet_name='Feature Flags') except Exception as e: raise RuntimeError(f'Failed to read Excel file {excel_file}: {e}') from e required_cols = {'Name', 'Team Owner'} if not required_cols.issubset(df.columns): raise ValueError(f'Excel file must contain columns: {required_cols}, found: {df.columns}') results: list[str] = [] for _, row in df.iterrows(): flag_name = row['Name'] team_owner = row['Team Owner'] if pd.notna(row['Team Owner']) else None if not flag_name or not team_owner: logger.warning(f'Skipping row with missing data: {row.to_dict()}') results.append('Skipped - Missing Data') continue if only_team and str(team_owner).strip() != only_team: logger.info(f"Skipping flag '{flag_name}' (team_owner='{team_owner}') since filter='{only_team}'") results.append('Skipped - Not Matching Filter') continue try: logger.info(f"Backfilling tag '{team_owner}' for flag '{flag_name}'...") success = splitio.associate_tags_to_split(flag_name, [team_owner]) if success: logger.info(f"✅ Successfully tagged '{flag_name}' with '{team_owner}'") results.append('True') else: logger.warning(f"⚠️ Flag '{flag_name}' not found (404).") results.append('False') except Exception as e: logger.error(f"❌ Failed to backfill tag for '{flag_name}': {e}") results.append(f'Error: {e}') df['Result'] = results output_file = excel_file.replace('.xlsx', '_with_results.xlsx') try: df.to_excel(output_file, sheet_name='Feature Flags', index=False) logger.info(f'Results written to {output_file}') except Exception as e: logger.error(f'Failed to write results to Excel: {e}') def parse_args() -> argparse.Namespace: """ Parse command-line arguments for the CLI utility. Returns: argparse.Namespace: Parsed command-line arguments. """ parser = argparse.ArgumentParser(description='Feature Flag Utility') subparsers = parser.add_subparsers(dest='command', required=True) # Create command create_parser = subparsers.add_parser('create', help='Create a new feature flag') create_parser.add_argument('name', help='Feature flag name') create_parser.add_argument('--owners', required=True, help='Comma-separated owners') create_parser.add_argument('--teams', required=True, help='Team name (used as tag)') create_parser.add_argument('--jira', required=True, help='Jira ticket number (e.g., ABC-123)') create_parser.add_argument('--description', help='Optional description') create_parser.add_argument('--traffic-type', default='user', help='Traffic type (default: user)') # Audit command audit_parser = subparsers.add_parser('audit', help='Audit existing feature flags') audit_parser.add_argument( '--output', choices=['excel', 'json', 'table'], default='excel', help='Output format (default: excel)' ) audit_parser.add_argument( '--filter-team', help='Optional team name to filter audit results' ) # Backfill Tags command backfill_parser = subparsers.add_parser('backfill-tags', help='Backfill tags from an Excel audit file') backfill_parser.add_argument( '--file', required=True, help='Path to the Excel file (must contain "Feature Flags" sheet with Name and Tags columns)' ) backfill_parser.add_argument( '--only-team', help='Optional team filter (only backfill flags belonging to this team)' ) args = parser.parse_args() return args def entry_point() -> None: """ CLI entry point for the feature flag utility. This function handles environment validation, argument parsing, and dispatching the correct handler based on the subcommand. """ try: if not HARNESS_API_KEY or not SPLITIO_WORKSPACE_ID: raise ValueError('Missing required environment variables: HARNESS_API_KEY and SPLIT_WORKSPACE_ID') splitio = SplitIOClient() args = parse_args() if args.command == 'create': owners = [owner.strip() for owner in args.owners.split(',')] teams = [team.strip() for team in args.teams.split(',')] create_flag( name=args.name, owners=owners, teams=teams, jira=args.jira, description=args.description or '', traffic_type=args.traffic_type, splitio=splitio ) elif args.command == 'audit': audit_feature_flags( splitio=splitio, output=args.output, filter_team=args.filter_team ) elif args.command == 'backfill-tags': backfill_tags_from_excel( splitio=splitio, excel_file=args.file, only_team=args.only_team ) else: raise ValueError(f'Unknown command: {args.command}') except Exception as e: logger.error(f'Unexpected error: {e}') sys.exit(1) if __name__ == '__main__': entry_point()