blob: 21252044da30256bd719f921188352181355076d [file]
#!/usr/bin/env python3
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# Prepares Ranger Admin runtime using configuration YAML and env variables: renders conf/ranger-admin-site.xml, stores the
# DB password in the credential store, imports the core schema, applies pending SQL/Java patches and sets user passwords.
import glob
import logging
import os
import re
import shlex
import socket
import subprocess
import sys
import tempfile
import time
import xml.etree.ElementTree as ET
import yaml
RANGER_ADMIN_DIR = os.path.join(os.environ.get('RANGER_HOME', '/opt/ranger'), 'admin')
RANGER_CONFIGS = os.environ.get('RANGER_ADMIN_CONFIGS', os.path.join(RANGER_ADMIN_DIR, 'configs'))
RANGER_CONF_DIR = os.environ.get('RANGER_CONF_DIR', os.path.join(RANGER_ADMIN_DIR, 'ews/webapp/WEB-INF/classes/conf'))
RANGER_LOG_DIR = os.environ.get('RANGER_ADMIN_LOG_DIR', '/var/log/ranger')
WEBAPP_DIR = os.path.join(RANGER_ADMIN_DIR, 'ews/webapp')
# colors only when attached to a terminal (e.g. docker compose with tty), plain text otherwise (e.g. log collectors)
COLOR = sys.stdout.isatty() and 'NO_COLOR' not in os.environ
def style(text, code):
return f'\033[{code}m{text}\033[0m' if COLOR else text
class ConsoleFormatter(logging.Formatter):
LEVELS = {logging.INFO: ('INFO ', '32'), logging.WARNING: ('WARN ', '33'), logging.ERROR: ('ERROR', '1;31')}
def format(self, record):
label, code = self.LEVELS.get(record.levelno, (record.levelname[:5], '0'))
return f"{style(self.formatTime(record, '%H:%M:%S'), '2')} {style(label, code)} {record.getMessage()}"
console_handler = logging.StreamHandler(sys.stdout)
file_handler = logging.FileHandler(os.path.join(RANGER_LOG_DIR, 'dba.log'))
console_handler.setFormatter(ConsoleFormatter())
file_handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)-5s %(message)s'))
logging.basicConfig(level=logging.INFO, handlers=(console_handler, file_handler))
LOG = logging.getLogger('dba')
def print_section(title):
print(style(f'━━━ ▶ {title} '.ljust(110, '━'), '1;36'), flush=True)
def indent(text):
return '\n'.join(style(' │ ', '2') + line for line in text.splitlines())
VERSION_TABLE = 'x_db_version_h'
JAVA_PATCH_RE = re.compile(r'^(Patch\w*_J(\d{5}))\.class$')
JISQL_ERR_RE = re.compile(r'^(SQLException|Cannot (find|instantiate) the driver)', re.M)
# login_id, default password seeded by core schema, x_db_version_h marker used by db_setup.py, env variable
BUILTIN_USERS = (('admin', 'admin', 'DEFAULT_ADMIN_UPDATE', 'RANGER_ADMIN_PASSWORD'),
('rangerusersync', 'rangerusersync', 'DEFAULT_RANGER_USERSYNC_UPDATE', 'RANGER_USERSYNC_PASSWORD'),
('rangertagsync', 'rangertagsync', 'DEFAULT_RANGER_TAGSYNC_UPDATE', 'RANGER_TAGSYNC_PASSWORD'),
('keyadmin', 'keyadmin', 'DEFAULT_KEYADMIN_UPDATE', 'RANGER_KEYADMIN_PASSWORD'))
# keyed by JDBC sub-protocol: db script directory, readiness query, table-exists query, x_db_version_h insert
FLAVORS = {
'postgresql': ('postgres', 'select 1;',
"select table_name from information_schema.tables where table_catalog=current_database() and table_name='{table}';",
"insert into x_db_version_h (version, inst_at, inst_by, updated_at, updated_by, active) values ('{version}', current_timestamp, '{inst_by}', current_timestamp, '{host}', 'Y');"),
'mysql': ('mysql', 'select 1;',
"show tables like '{table}';",
"insert into x_db_version_h (version, inst_at, inst_by, updated_at, updated_by, active) values ('{version}', current_timestamp, '{inst_by}', current_timestamp, '{host}', 'Y');"),
'oracle': ('oracle', 'select 1 from dual;',
"select table_name from user_tables where table_name=upper('{table}');",
"insert into x_db_version_h (id, version, inst_at, inst_by, updated_at, updated_by, active) values (X_DB_VERSION_H_SEQ.nextval, '{version}', sysdate, '{inst_by}', sysdate, '{host}', 'Y');"),
}
class BootstrapError(Exception):
def __init__(self, message, details=''):
super().__init__(message)
self.details = details
def describe(self):
return f'{self}\n{indent(self.details)}' if self.details else str(self)
def to_property_value(value):
if isinstance(value, bool):
return str(value).lower()
return '' if value is None else str(value)
def render_site_xml():
# a mounted ranger-admin-site.xml is the configuration, as ranger.sh copies it to conf; nothing is rendered over it
site_xml = os.path.join(RANGER_CONFIGS, 'ranger-admin-site.xml')
if os.path.isfile(site_xml):
if os.environ.get('AUDIT_INDEX_STORE'):
LOG.warning(f'env AUDIT_INDEX_STORE is ignored: ranger.audit.source.type is taken from {site_xml}')
LOG.info(f'✔ Using {site_xml} as it is, conf/ranger-admin-site.xml is not rendered from YAML')
return
db_type = os.environ.get('RANGER_DB_TYPE')
if not db_type:
raise BootstrapError(f'env RANGER_DB_TYPE is not set and {site_xml} does not exist')
site_yaml = os.path.join(RANGER_CONFIGS, f'ranger-admin-site-{db_type}.yaml')
if not os.path.isfile(site_yaml):
raise BootstrapError(f'configuration not found: neither {site_xml} nor {site_yaml} exists')
with open(site_yaml) as f:
overrides = yaml.safe_load(f) or {}
if not isinstance(overrides, dict) or any(isinstance(value, (dict, list)) for value in overrides.values()):
raise BootstrapError(f'{site_yaml} must be a flat map of Ranger Admin property names to values')
# the YAML file is the complete Ranger Admin configuration; nothing is taken from conf.dist
props = {str(name): to_property_value(value) for name, value in overrides.items()}
if os.environ.get('AUDIT_INDEX_STORE'):
props['ranger.audit.source.type'] = os.environ['AUDIT_INDEX_STORE']
LOG.info(f"ranger.audit.source.type: {os.environ['AUDIT_INDEX_STORE']} (from env AUDIT_INDEX_STORE)")
root = ET.Element('configuration')
root.append(ET.Comment(f' generated by dba.py from {os.path.basename(site_yaml)} '))
for name, value in props.items():
prop = ET.SubElement(root, 'property')
ET.SubElement(prop, 'name').text = name
ET.SubElement(prop, 'value').text = value
ET.indent(root)
ET.ElementTree(root).write(os.path.join(RANGER_CONF_DIR, 'ranger-admin-site.xml'), encoding='utf-8', xml_declaration=True)
LOG.info(f'✔ Rendered conf/ranger-admin-site.xml with {len(props)} properties from {site_yaml}')
def load_site_properties():
props = {}
for file_name in ('ranger-admin-default-site.xml', 'ranger-admin-site.xml'):
for prop in ET.parse(os.path.join(RANGER_CONF_DIR, file_name)).getroot().iter('property'):
props[prop.findtext('name', '').strip()] = prop.findtext('value', '').strip()
return props
def java_cmd(*args):
java = os.path.join(os.environ['JAVA_HOME'], 'bin', 'java') if os.environ.get('JAVA_HOME') else 'java'
return [java, *args]
class RangerDB:
def __init__(self, props):
self.url = props.get('ranger.jpa.jdbc.url', '')
self.driver = props.get('ranger.jpa.jdbc.driver', '')
self.user = props.get('ranger.jpa.jdbc.user', '')
self.jar = props.get('ranger.jdbc.sqlconnectorjar', '')
self.host = socket.gethostname()
self.inst_by = f"Ranger {os.environ.get('RANGER_VERSION', '')}".strip()
match = re.match(r'^jdbc:(?:log4jdbc:)?(\w+):', self.url)
if not match or match.group(1) not in FLAVORS:
raise BootstrapError(f'unsupported ranger.jpa.jdbc.url: {self.url}')
if not all((self.driver, self.user, self.jar, os.environ.get('RANGER_ADMIN_DB_PASSWORD'))):
raise BootstrapError('ranger.jpa.jdbc.driver, ranger.jpa.jdbc.user, ranger.jdbc.sqlconnectorjar and env RANGER_ADMIN_DB_PASSWORD are required')
if not os.path.isfile(self.jar):
raise BootstrapError(f'JDBC driver {self.jar} (ranger.jdbc.sqlconnectorjar) not found')
self.db_dir, self.ready_query, self.table_query, self.insert_version_query = FLAVORS[match.group(1)]
LOG.info(f'Database: flavor={self.db_dir}, url={self.url}, user={self.user}, driver={self.driver}, jar={self.jar}')
# Jisql reads the password from a file (-pf), which keeps it off the process command line
with tempfile.NamedTemporaryFile('w', prefix='jisql-', delete=False) as pwd_file:
pwd_file.write(os.environ['RANGER_ADMIN_DB_PASSWORD'])
self.password_file = pwd_file.name
def jisql(self, *args):
jvm_args = ['-Djava.security.egd=file:///dev/urandom'] if self.db_dir == 'oracle' else []
cmd = java_cmd(*jvm_args, '-cp', f'{self.jar}:{RANGER_ADMIN_DIR}/jisql/lib/*', 'org.apache.util.sql.Jisql',
'-driver', self.driver, '-cstring', self.url, '-u', self.user, '-pf', self.password_file, '-noheader', '-trim', *args)
ret = subprocess.run(cmd, capture_output=True, text=True)
# Jisql exits with 0 when connection fails, hence errors are detected from stderr as well
if ret.returncode != 0 or JISQL_ERR_RE.search(ret.stderr):
raise BootstrapError(f'Jisql exited with code {ret.returncode}', '\n'.join(dict.fromkeys(ret.stderr.strip().splitlines())))
return ret.stdout
def query(self, sql):
return [line.split('|')[0].strip() for line in self.jisql('-c', ';', '-query', sql).splitlines() if line.strip()]
def wait_until_ready(self, timeout):
deadline = time.time() + timeout
while True:
try:
if '1' in self.query(self.ready_query):
LOG.info(f'✔ Connected to {self.url}')
return
except BootstrapError as err:
LOG.info(f'⏳ Waiting for database: {(err.details or str(err)).splitlines()[-1][:120]}')
if time.time() > deadline:
raise BootstrapError(f'database {self.url} is not reachable after {timeout}s')
time.sleep(5)
def applied_versions(self):
if not any(VERSION_TABLE in row.lower() for row in self.query(self.table_query.format(table=VERSION_TABLE))):
LOG.info(f'Table {VERSION_TABLE} not found: database is empty')
return set()
return set(self.query(f"select version from {VERSION_TABLE} where active='Y';"))
def mark_applied(self, version):
self.query(self.insert_version_query.format(version=version, inst_by=self.inst_by, host=self.host))
def import_file(self, file_name):
self.jisql('-c', ';', '-input', file_name)
def run_java(self, class_name, *args):
classpath = ':'.join((f'{WEBAPP_DIR}/WEB-INF/classes/conf', f'{WEBAPP_DIR}/WEB-INF/classes/lib/*', f'{WEBAPP_DIR}/WEB-INF/', f'{WEBAPP_DIR}/META-INF/',
f'{WEBAPP_DIR}/WEB-INF/lib/*', f'{WEBAPP_DIR}/WEB-INF/classes/', f'{WEBAPP_DIR}/WEB-INF/classes/META-INF', self.jar))
cmd = java_cmd(*shlex.split(os.environ.get('JAVA_OPTS', '')), f"-Xmx{os.environ.get('RANGER_ADMIN_MAX_HEAP', '1g')}",
f'-Dlogdir={RANGER_LOG_DIR}', f'-Dlogback.configurationFile=file:{RANGER_CONF_DIR}/logback.xml',
f"-Duser={os.environ.get('USER', 'ranger')}", f'-Dhostname={self.host}', '-cp', classpath, class_name, *args)
# logback.xml in the container conf logs to console as well; output is returned to be shown under the operation result
ret = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
return ret.returncode, ret.stdout.rstrip()
def create_credential_store(props):
provider = props.get('ranger.credential.provider.path')
alias = props.get('ranger.jpa.jdbc.credential.alias')
if not (provider and alias):
LOG.info('ranger.credential.provider.path or ranger.jpa.jdbc.credential.alias is not set: DB password is not stored in credential store')
return
# cred/lib lacks hadoop-common, which buildks needs; it is available in webapp libs
ret = subprocess.run(java_cmd('-cp', f'{RANGER_ADMIN_DIR}/cred/lib/*:{WEBAPP_DIR}/WEB-INF/lib/*', 'org.apache.ranger.credentialapi.buildks', 'create', alias,
'-value', os.environ['RANGER_ADMIN_DB_PASSWORD'], '-provider', f'jceks://file{provider}'), capture_output=True, text=True)
if ret.returncode != 0:
raise BootstrapError(f'failed to store DB password in {provider}\n{ret.stdout.strip()}\n{ret.stderr.strip()}')
LOG.info(f'✔ Stored DB password with alias {alias} in credential store {provider}')
def import_core_schema(db, applied):
if 'CORE_DB_SCHEMA' in applied:
LOG.info(f'Core schema is already imported ({len(applied)} active entries in {VERSION_TABLE})')
return applied
schema_file = os.path.join(RANGER_ADMIN_DIR, 'db', db.db_dir, 'optimized/current', f'ranger_core_db_{db.db_dir}.sql')
start = time.time()
LOG.info(f'Importing core schema {schema_file}')
try:
db.import_file(schema_file)
except BootstrapError as err:
LOG.error(f'✖ Import of core schema {os.path.basename(schema_file)} failed: {err.describe()}')
raise BootstrapError('core schema import failed') from None
# as db_setup.py does: entries inserted by the core schema script are attributed to the installed version
db.query(f"update {VERSION_TABLE} set inst_by='{db.inst_by}' where active='Y' and updated_by='localhost';")
applied = db.applied_versions()
if 'CORE_DB_SCHEMA' not in applied:
raise BootstrapError(f'core schema import did not record CORE_DB_SCHEMA in {VERSION_TABLE}')
LOG.info(f"✔ Imported core schema in {time.time() - start:.1f}s: {len(applied)} active entries in {VERSION_TABLE}, attributed to '{db.inst_by}'")
return applied
def apply_java_patch(db, applied, class_file, progress):
class_name, version = JAVA_PATCH_RE.match(os.path.basename(class_file)).groups()
if f'J{version}' in applied:
return
start = time.time()
ret, output = db.run_java(f'org.apache.ranger.patch.{class_name}')
if ret != 0:
raise BootstrapError(f'Java patch {class_name} exited with code {ret}', output)
db.mark_applied(f'J{version}')
applied.add(f'J{version}')
# output of the patch is in ranger_db_patch.log; it is shown on console only when the patch fails
LOG.info(f'{progress} ✔ Applied Java patch {class_name} in {time.time() - start:.1f}s')
def java_patches(prefix):
classes = glob.glob(os.path.join(WEBAPP_DIR, 'WEB-INF/classes/org/apache/ranger/patch', f'{prefix}*.class'))
return sorted((c for c in classes if JAVA_PATCH_RE.match(os.path.basename(c))), key=lambda c: JAVA_PATCH_RE.match(os.path.basename(c)).group(2))
def mark_patches_status(db, marker):
# as db_setup.py does: records that all patches of the installed version have been applied
if not db.query(f"select version from {VERSION_TABLE} where version='{marker}' and inst_by='{db.inst_by}' and active='Y';"):
db.mark_applied(marker)
LOG.info(f"✔ Recorded {marker} for '{db.inst_by}' in {VERSION_TABLE}")
def apply_patches(db, applied):
sql_patches = sorted(glob.glob(os.path.join(RANGER_ADMIN_DIR, 'db', db.db_dir, 'patches', '*.sql')))
pending_sql = [patch for patch in sql_patches if os.path.basename(patch).split('-')[0] not in applied]
LOG.info(f'SQL patches: {len(sql_patches)} available, {len(pending_sql)} pending')
for idx, patch_file in enumerate(pending_sql, 1):
name = os.path.basename(patch_file)
version = name.split('-')[0]
progress = f'[{idx}/{len(pending_sql)}]'
start = time.time()
try:
for pre_patch in java_patches(f'PatchPreSql_{version}_'):
apply_java_patch(db, applied, pre_patch, f'{progress} ↳ pre-SQL')
db.import_file(patch_file)
db.mark_applied(version)
applied.add(version)
for post_patch in java_patches(f'PatchPostSql_{version}_'):
apply_java_patch(db, applied, post_patch, f'{progress} ↳ post-SQL')
except BootstrapError as err:
LOG.error(f'{progress} ✖ SQL patch {name} failed, after {idx - 1} of {len(pending_sql)} pending SQL patches were applied: {err.describe()}')
raise BootstrapError(f'SQL patch {name} failed ({idx}/{len(pending_sql)})') from None
LOG.info(f'{progress} ✔ Applied SQL patch {name} in {time.time() - start:.1f}s')
mark_patches_status(db, 'DB_PATCHES')
patches = java_patches('Patch')
pending_patches = [patch for patch in patches if f'J{JAVA_PATCH_RE.match(os.path.basename(patch)).group(2)}' not in applied]
LOG.info(f'Java patches: {len(patches)} available, {len(pending_patches)} pending')
for idx, patch in enumerate(pending_patches, 1):
progress = f'[{idx}/{len(pending_patches)}]'
try:
apply_java_patch(db, applied, patch, progress)
except BootstrapError as err:
LOG.error(f'{progress} ✖ {err}, after {idx - 1} of {len(pending_patches)} pending Java patches were applied' + (f':\n{indent(err.details)}' if err.details else ''))
raise BootstrapError(f'Java patch {os.path.basename(patch)[:-6]} failed ({idx}/{len(pending_patches)})') from None
mark_patches_status(db, 'JAVA_PATCHES')
def update_builtin_user_passwords(db, applied):
if 'DEFAULT_ALL_ADMIN_UPDATE' in applied:
LOG.info('Default passwords of built-in users are already updated (DEFAULT_ALL_ADMIN_UPDATE)')
return
users = [user for user in BUILTIN_USERS if user[2] not in applied and os.environ.get(user[3])]
for login_id, _, marker, env in BUILTIN_USERS:
if marker in applied:
LOG.info(f'Default password of {login_id} is already updated ({marker})')
elif not os.environ.get(env):
LOG.warning(f'env {env} is not set: default password of {login_id} is not updated')
if not users:
return
start = time.time()
names = ', '.join(login_id for login_id, _, _, _ in users)
# arguments: <login_id> <current password> <new password> ... -default
args = [arg for login_id, default_pwd, _, env in users for arg in (login_id, default_pwd, os.environ[env])]
ret, output = db.run_java('org.apache.ranger.patch.cliutil.ChangePasswordUtil', *args, '-default')
if ret not in (0, 2):
LOG.error(f'✖ Update of default passwords of {names} failed, ChangePasswordUtil exited with code {ret}:\n{indent(output)}')
raise BootstrapError('update of default passwords failed')
# as db_setup.py does: a single marker when passwords of all built-in users are updated together
markers = ['DEFAULT_ALL_ADMIN_UPDATE'] if len(users) == len(BUILTIN_USERS) else [marker for _, _, marker, _ in users]
for marker in markers:
db.mark_applied(marker)
# output of ChangePasswordUtil is in ranger_db_patch.log; it is shown on console only when it fails
if ret == 2:
LOG.warning(f"Update of default passwords of {names} skipped: current password of a user is not the default one, i.e. it was already changed; recorded {', '.join(markers)}")
else:
LOG.info(f"✔ Updated default passwords of {names} in {time.time() - start:.1f}s, recorded {', '.join(markers)}")
def main():
start = time.time()
print_section('dba.py · Ranger Admin configuration and database bootstrap')
LOG.info(f"Preparing Ranger Admin {os.environ.get('RANGER_VERSION', '')} (RANGER_DB_TYPE={os.environ.get('RANGER_DB_TYPE', '')})")
render_site_xml()
props = load_site_properties()
db = RangerDB(props)
try:
create_credential_store(props)
db.wait_until_ready(int(os.environ.get('RANGER_DB_WAIT_TIMEOUT', '300')))
applied = import_core_schema(db, db.applied_versions())
apply_patches(db, applied)
update_builtin_user_passwords(db, applied)
finally:
os.remove(db.password_file)
LOG.info(f'✔ Ranger Admin configuration and database are ready in {time.time() - start:.1f}s')
if __name__ == '__main__':
try:
main()
except (BootstrapError, OSError, ET.ParseError, yaml.YAMLError) as err:
LOG.error(f'✖ Ranger Admin bootstrap failed: {err}')
sys.exit(1)