#! /usr/bin/env python """Git Fusion submit triggers. These triggers coordinate with Git Fusion to support git atomic pushes. Service user accounts use p4 user Reviews to manage list of locked files. There is one service user per Git Fusion instance and one for non Git Fusion submits. This trigger is compatible with python versions 2.x >= 2.6 and >= 3.3 The trigger is compatible with p4d versions >= 2015.1. For distributed p4d triggers are installed only on the commit server. Submits from edge servers are handled by the commit server. """ # pylint:disable=W9903 # Skip localization/translation warnings about config strings # here at the top of the file. # -- Configuration ------------------------------------------------------------ # Edit these constants to match your p4d server and environment. # Set the external configuration file location (relative to the script location # or an absolute path) P4GF_TRIGGER_CONF = "p4gf_submit_trigger.cfg" P4GF_TRIGGER_CONF_SETTINGS = "" CONFIG_PATH_ARG_PREFIX = '--config-path=' # prefix to identify alternate config path argument CONFIG_PATH_ARG = '' # actual arg set into trigger entries by --generate CONFIG_PATH_INDEX = None # used to insert CONFIG_PATH_ARG back into sys.argv # for sudo re-invocation with --install option SERVER_ID_ARG_PREFIX = '--git-fusion-server=' # prefix to identify server id # If a trigger configuration file is found, the configuration will be read from # that file: Anything set there will override the configuration below. # For unicode servers uncomment the following line # CHARSET = ['-C', 'utf8'] CHARSET = [] P4GF_P4_BIN_PATH_VAR_NAME = "P4GF_P4_BIN_PATH" P4GF_P4_BIN_CONFIG_OPTION_NAME = "P4GF_P4_BIN" P4GF_P4_BIN_PATH_CONFIG_OPTION_NAME = "P4GF_P4_BIN_PATH" # Set to the location of the p4 binary. # When in doubt, change this to an absolute path. P4GF_P4_BIN_PATH = "p4" # For Windows systems use no spaces in the p4.exe path # P4GF_P4_BIN_PATH = "C:\PROGRA~1\Perforce\p4.exe" # If running P4D with a P4PORT bound to a specific network (as opposed to # all of them, as in P4PORT=1666), then set this to the name of the host to # which P4D is bound (e.g. p4prod.example.com). See also P4PORT below. DEFAULT_P4HOST = 'localhost' # By default P4PORT is set from the p4d trigger %serverport% argument. # Admins optionally may override the %serverport% by setting P4PORT here to # a non-empty string. P4PORT = None # If P4TICKETS is set , export value via os.environ P4TICKETS = None # If P4TRUST is set , export value via os.environ P4TRUST = None # End P4 configurables # ----------------------------------------------------------------------------- import json import sys import inspect import uuid # Determine python version PYTHON3 = True if sys.hexversion < 0x03000000: PYTHON3 = False # Exit codes for triggers, sys.exit(CODE) P4PASS = 0 P4FAIL = 1 KEY_VIEW = 'view' KEY_STREAM = 'stream' # user password status from 'login -s' LOGIN_NO_PASSWD = 0 LOGIN_NEEDS_TICKET = 1 LOGIN_HAS_TICKET = 2 # Create a unique client name to use with 'p4' calls. # The client is not required, but it prevents failures # should the default client (hostname) # be identical to a Perforce depot name. P4CLIENT = "GF-TRIGGER-{0}".format(str(uuid.uuid1())[0:12]) # Import the configparser - either from python2 or python3 try: # python3.x import import configparser # pylint: disable=import-error PARSING_ERROR = configparser.Error # pylint: disable=invalid-name except ImportError: # python2.x import import cStringIO import ConfigParser PARSING_ERROR = ConfigParser.Error # pylint: disable=invalid-name configparser = ConfigParser # pylint: disable=invalid-name P4GF_USER = "git-fusion-user" import os import re import platform import getpass # Optional localization/translation support. # If the rest of Git Fusion's bin folder # was copied along with this file p4gf_submit_trigger.py, # then this block loads LC_MESSAGES .mo files # to support languages other than US English. try: from p4gf_l10n import _, NTR except ImportError: # Invalid name NTR() def NTR(x): # pylint: disable=invalid-name """No-TRanslate: Localization marker for string constants.""" return x _ = NTR # pylint:enable=W9903 # Import pwd for username -> gid # Only available on *nix if platform.system() != "Windows": import pwd import stat # Get and store the full paths to this script and the interpreter SCRIPT_PATH = os.path.realpath(__file__) PYTHON_PATH = sys.executable P4GF_TRIGGER_CONF = os.path.join(os.path.dirname(SCRIPT_PATH),"p4gf_submit_trigger.cfg") # Try and load external configuration CFG_SECTION = "configuration" CFG_EXTERNAL = False def get_datetime(with_year=True): """Generate a date/time string for the lock acquisition time.""" date = datetime.datetime.now() if with_year: return date.isoformat(sep=' ').split('.')[0] else: return date.strftime('%m-%d %H:%M:%S') def debug_log(msg, lineno=None): """Log message to the debug log.""" if not lineno: lineno = inspect.currentframe().f_back.f_lineno DEBUG_LOG_FILE.write("{0} {1} line:{2:5} :: {3}\n". format(PROCESS_PID, get_datetime(with_year=False), lineno, msg)) DEBUG_LOG_FILE.flush() def fix_text_encoding(text): """Ensure the string is encodable using the stdout encoding. :param str text: string to be re-coded, if necessary. :return: a safe version of the string which can be printed. """ encoding = sys.stdout.encoding if encoding is None: encoding = sys.getdefaultencoding() if not PYTHON3: text = unicode(text, encoding, 'replace') # pylint:disable=undefined-variable try: text.encode(encoding) return text except UnicodeEncodeError: bites = text.encode(encoding, 'backslashreplace') result = bites.decode(encoding, 'strict') return result def print_log(msg): """Print and optionally log to debug log.""" msg = fix_text_encoding(msg) print(msg) if DEBUG_LOG_FILE: debug_log(msg, lineno=inspect.currentframe().f_back.f_lineno) def is_trigger_install(): """Return true if this is a trigger installation invocation. Return true if arguments have: --install --install_trigger_entries --generate_trigger_entries """ install_args = ['--install','--install_trigger_entries', '--generate_trigger_entries'] for arg in sys.argv: if arg in install_args: return True return False MSG_CONFIG_FILE_MISSING = _( "Git Fusion Trigger: config file does not exist:'{path}'") MSG_CONFIG_PATH_NOT_ABSOLUTE = _( "Git Fusion Trigger: argument must provide an absolute path:'{path}'") def validate_config_path(config_path_file_path): """Ensure the config_path is absolute and exists.""" if not len(config_path_file_path) or not os.path.isabs(config_path_file_path): print(MSG_CONFIG_PATH_NOT_ABSOLUTE.format(path=CONFIG_PATH_ARG)) sys.exit(P4FAIL) if not is_trigger_install() and not os.path.exists(config_path_file_path): print(MSG_CONFIG_FILE_MISSING.format(path=config_path_file_path)) sys.exit(P4FAIL) return config_path_file_path def check_and_extract_no_config_from_args(): """Remove the --no-config argument from sys.arg. if --no-config was present remove --no-config from sys.arg if command not --install return True else return False """ for i, arg in enumerate(sys.argv): if arg == '--no-config': # Only the --install command needs to retain the --no-config arg if sys.argv[1] != '--install': del sys.argv[i] return True return False def check_and_extract_debug_from_args(): """Remove the --debug argument from sys.arg. if --debug was present remove --debug from sys.arg return True else return False """ for i, arg in enumerate(sys.argv): if arg == '--debug': del sys.argv[i] return True return False def extract_config_file_path_from_args(): """Remove and set the--config-path argument from sys.arg. If --config-path=/absolute/path/to/config argument exists: remove it from sys.arg, validate and return it Failed validation will FAIL the trigger with error message. Else return None """ global CONFIG_PATH_ARG, CONFIG_PATH_INDEX for i, arg in enumerate(sys.argv): if arg.startswith(CONFIG_PATH_ARG_PREFIX): CONFIG_PATH_ARG = arg CONFIG_PATH_INDEX = i config_path_file_path = arg.replace(CONFIG_PATH_ARG_PREFIX, '') del sys.argv[i] return validate_config_path(config_path_file_path) # missing the '=' at the end of --config-path=? elif arg.startswith(CONFIG_PATH_ARG_PREFIX[:-1]): CONFIG_PATH_ARG = arg print_log(_("'{path_arg}' argument must provide an absolute path.") .format(path_arg=CONFIG_PATH_ARG)) sys.exit(P4FAIL) return None def extract_server_id_from_args(): """Remove and set the --git-fusion-server argument from sys.arg.""" for i, arg in enumerate(sys.argv): if arg.startswith(SERVER_ID_ARG_PREFIX): del sys.argv[i] return arg.replace(SERVER_ID_ARG_PREFIX, '') return None def set_globals_from_config_file(): """Detect and parse the p4gf_submit_trigger.cfg setting configuration variables. A missing config file is passed. A missing default path is acceptable. A missing --config-path= is reported and failed earlier in extract_config_file_path_from_args(). """ # pylint: disable=too-many-branches global P4GF_TRIGGER_CONF, DEFAULT_P4HOST, CHARSET, DEBUG_LOG_PATH global P4GF_P4_BIN_PATH, P4PORT, P4TICKETS, P4TRUST, CFG_EXTERNAL global P4GF_TRIGGER_CONF_SETTINGS # If the path is absolute, just try and read it if isinstance(P4GF_TRIGGER_CONF, str) and not os.path.isabs(P4GF_TRIGGER_CONF): # If the path is relative, make it relative to the script, not CWD P4GF_TRIGGER_CONF = os.path.abspath(os.path.join( os.path.dirname(SCRIPT_PATH), P4GF_TRIGGER_CONF)) if isinstance(P4GF_TRIGGER_CONF, str) and os.path.isfile(P4GF_TRIGGER_CONF): try: TRIG_CONFIG = configparser.ConfigParser() # pylint: disable=invalid-name TRIG_CONFIG.read(P4GF_TRIGGER_CONF) if TRIG_CONFIG.has_section(CFG_SECTION): CFG_EXTERNAL = True if TRIG_CONFIG.has_option(CFG_SECTION, "DEFAULT_P4HOST"): DEFAULT_P4HOST = TRIG_CONFIG.get(CFG_SECTION, "DEFAULT_P4HOST") P4GF_TRIGGER_CONF_SETTINGS += "\n DEFAULT_P4HOST={0}".format(DEFAULT_P4HOST) if TRIG_CONFIG.has_option(CFG_SECTION, "P4CHARSET"): CHARSET = ["-C", TRIG_CONFIG.get(CFG_SECTION, "P4CHARSET")] P4GF_TRIGGER_CONF_SETTINGS += "\n CHARSET={0}".format(CHARSET) if TRIG_CONFIG.has_option(CFG_SECTION, P4GF_P4_BIN_CONFIG_OPTION_NAME): P4GF_P4_BIN_PATH = TRIG_CONFIG.get(CFG_SECTION, P4GF_P4_BIN_CONFIG_OPTION_NAME) P4GF_TRIGGER_CONF_SETTINGS += "\n P4GF_P4_BIN_PATH={0}".format( P4GF_P4_BIN_PATH) elif TRIG_CONFIG.has_option(CFG_SECTION, P4GF_P4_BIN_PATH_CONFIG_OPTION_NAME): P4GF_P4_BIN_PATH = TRIG_CONFIG.get(CFG_SECTION, P4GF_P4_BIN_PATH_CONFIG_OPTION_NAME) P4GF_TRIGGER_CONF_SETTINGS += "\n P4GF_P4_BIN_PATH={0}".format( P4GF_P4_BIN_PATH) if TRIG_CONFIG.has_option(CFG_SECTION, "P4PORT"): P4PORT = TRIG_CONFIG.get(CFG_SECTION, "P4PORT") P4GF_TRIGGER_CONF_SETTINGS += "\n P4PORT={0}".format(P4PORT) if TRIG_CONFIG.has_option(CFG_SECTION, "P4TICKETS"): P4TICKETS = TRIG_CONFIG.get(CFG_SECTION, "P4TICKETS") P4GF_TRIGGER_CONF_SETTINGS += "\n P4TICKETS={0}".format(P4TICKETS) if TRIG_CONFIG.has_option(CFG_SECTION, "P4TRUST"): P4TRUST = TRIG_CONFIG.get(CFG_SECTION, "P4TRUST") P4GF_TRIGGER_CONF_SETTINGS += "\n P4TRUST={0}".format(P4TRUST) if TRIG_CONFIG.has_option(CFG_SECTION, "DEBUG-LOG-PATH"): DEBUG_LOG_PATH = TRIG_CONFIG.get(CFG_SECTION, "DEBUG-LOG-PATH") P4GF_TRIGGER_CONF_SETTINGS += "\n DEBUG_LOG_PATH={0}".format(DEBUG_LOG_PATH) else: raise Exception(_("Didn't find section {section} in configuration file {path}") .format(section=CFG_SECTION, path=P4GF_TRIGGER_CONF)) except Exception as config_e: # pylint: disable=broad-except print(_("Failed to load configuration from external file {path}"). format(path=P4GF_TRIGGER_CONF)) print(_("Error: {exception}").format(exception=config_e)) sys.exit(P4FAIL) def skip_trigger_if_gf_user(): """Permit Git Fusion to operate without engaging its own triggers. Triggers are to be applied only to non P4GF_USER. This is required for Windows as the trigger bash filter does not exist P4GF_USER changes to the p4gf_config are reviewed within the core Git Fusion process as it holds the locks and knows what it is doing. XXX One day this should probably only be done within the trigger to minimize duplicated code. """ # The option --config-path= trigger argument has been # removed from sys.argv at the time this is called # Permit the '--