blob: 9705a50c3a4c715ae3c109fc2ae70916d32c04f5 [file]
# 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.
import subprocess
import json
import io
import tarfile
import time
import docker
import tempfile, os
import xml.etree.ElementTree as ET
import requests
KMS_CONTAINER_NAME = "ranger-kms"
KMS_LOG_FILE = "/var/log/ranger/kms/ranger-kms-ranger-kms.rangernw-root.log"
BASE_URL_RANGER = "http://localhost:6080/service/public/v2/api/policy"
BASE_URL_RANGER_USERS = "http://localhost:6080/service/xusers/secure/users"
BASE_URL_RANGER_USERS_BY_NAME = "http://localhost:6080/service/xusers/users/userName"
RANGER_ADMIN_AUTH = ("admin", "rangerR0cks!")
RANGER_KMS_AUTH = ('keyadmin', 'rangerR0cks!') # Ranger key admin user
KMS_SERVICE_NAME = "dev_kms"
TEST_USER = "testuser"
TESTUSER_PARAMS = {}
BASE_URL = "http://ranger-kms.rangernw:9292/kms/v1"
PARAMS = {"user.name": "keyadmin"}
_docker_client = None
def get_docker_client():
global _docker_client
if _docker_client is None:
_docker_client = docker.from_env()
return _docker_client
def get_kms_container():
"""Return ranger-kms container (lazy — no Docker call at import time)."""
return get_docker_client().containers.get(KMS_CONTAINER_NAME)
class _LazyKmsContainer:
"""Backward-compatible alias; resolves ranger-kms on first use."""
def __getattr__(self, name):
return getattr(get_kms_container(), name)
container = _LazyKmsContainer()
KEYADMIN_PRINCIPAL = "keyadmin@EXAMPLE.COM"
KEYADMIN_KEYTAB = "/etc/keytabs/keyadmin.keytab"
KDC_CONTAINER = "ranger-kdc"
TESTUSER = "testuser"
TESTUSER_PRINCIPAL = f"{TESTUSER}@EXAMPLE.COM"
TESTUSER_KEYTAB = f"/etc/keytabs/{TESTUSER}.keytab"
def fetch_logs():
try:
cmd = f"docker exec {KMS_CONTAINER_NAME} tail -n 100 {KMS_LOG_FILE}"
logs = subprocess.check_output(cmd, shell=True, text=True)
error_logs = [line for line in logs.split("\n") if "ERROR" in line or "Exception" in line]
return "\n".join(error_logs) if error_logs else "No recent errors in logs."
except subprocess.CalledProcessError as e:
return f"Failed to fetch logs from container. Command failed with exit code {e.returncode}: {e.output}"
def find_conflicting_policies(service, keyname_pattern):
# Find any existing policy on this service whose keyname resource overlaps with our pattern, regardless of policy name."""
response = requests.get(
BASE_URL_RANGER,
auth=RANGER_KMS_AUTH,
params={"serviceName": service},
)
if response.status_code != 200:
return []
results = response.json()
if not isinstance(results, list):
return []
conflicting_ids = []
for policy in results:
resources = policy.get("resources", {})
keyname_values = resources.get("keyname", {}).get("values", [])
if keyname_pattern in keyname_values:
conflicting_ids.append(policy["id"])
return conflicting_ids
class KerberosRequests:
def _curl(self, method, url, json_body=None, params=None):
full_url = url
if params:
if isinstance(params, dict):
items = params.items()
else:
items = params # list of tuples — allows duplicate keys e.g. [("key","k1"),("key","k2")]
qs = "&".join(f"{k}={v}" for k, v in items)
full_url = f"{url}?{qs}"
# Build curl args as a shell-safe string so we can append the body read
# in the same exec_run call — avoids a second docker exec for "cat".
curl_args = [
"curl", "-s", "-o", "/tmp/curl_body.txt", "-w", "%{http_code}",
"--negotiate", "-u", ":",
"-X", method.upper(),
"-H", "Content-Type: application/json",
]
if json_body is not None:
curl_args += ["-d", json.dumps(json_body)]
curl_args.append(full_url)
# Escape each argument for a POSIX shell one-liner
def _sh_quote(s):
return "'" + s.replace("'", "'\\''") + "'"
curl_cmd = " ".join(_sh_quote(a) for a in curl_args)
# Single exec: run curl then print a fixed separator then the body.
# Output format: <status_code>\n---BODY---\n<body text>
shell_cmd = f'{curl_cmd}; echo "\\n---BODY---"; cat /tmp/curl_body.txt'
exit_code, output = get_kms_container().exec_run(
["sh", "-c", shell_cmd], user="root"
)
raw = output.decode() if output else ""
separator = "\n---BODY---\n"
if separator in raw:
status_part, body = raw.split(separator, 1)
else:
status_part, body = raw.strip(), ""
try:
status_code = int(status_part.strip())
except ValueError:
status_code = 0
return _FakeResponse(status_code, body)
def post(self, url, headers=None, json=None, params=None, **_):
return self._curl("POST", url, json_body=json, params=params)
def delete(self, url, params=None, **_):
return self._curl("DELETE", url, params=params)
def get(self, url, headers=None, params=None, **_):
return self._curl("GET", url, params=params)
def put(self, url, headers=None, json=None, params=None, **_):
return self._curl("PUT", url, json_body=json, params=params)
class _FakeResponse:
def __init__(self, status_code, body):
self.status_code = status_code
self._body = body
self.text = body # for test_keyDetails.py compatibility
def json(self):
return __import__("json").loads(self._body)
def __repr__(self):
return f"<Response [{self.status_code}]>"
krb_requests = KerberosRequests()
def _ensure_kdc_principal_keytab(principal: str, dest_container_name: str, dest_keytab_path: str, kdc_tmp_path: str, ) -> None:
# create principal in KDC, export keytab, copy to target container
kdc = get_docker_client().containers.get(KDC_CONTAINER)
dest = get_docker_client().containers.get(dest_container_name)
exit_code, output = kdc.exec_run(f'kadmin.local -q "getprinc {principal}"', user="root")
if exit_code != 0 or b"does not exist" in output:
ec, out = kdc.exec_run(f'kadmin.local -q "addprinc -randkey {principal}"', user="root")
if ec != 0:
raise RuntimeError(f"addprinc failed for {principal}: {out.decode()}")
exit_code, output = kdc.exec_run(
f'kadmin.local -q "xst -k {kdc_tmp_path} {principal}"', user="root"
)
if exit_code != 0:
raise RuntimeError(f"keytab export failed for {principal}: {output.decode()}")
local = os.path.join(tempfile.gettempdir(), os.path.basename(dest_keytab_path))
subprocess.check_call(f"docker cp {KDC_CONTAINER}:{kdc_tmp_path} {local}", shell=True)
subprocess.check_call(f"docker cp {local} {dest_container_name}:{dest_keytab_path}", shell=True)
dest.exec_run(f"chmod 400 {dest_keytab_path}", user="root")
os.remove(local)
def ensure_keyadmin_keytab():
# creation of keyadmin@example.com & install keytab in ranger-kms
_ensure_kdc_principal_keytab(KEYADMIN_PRINCIPAL, KMS_CONTAINER_NAME, KEYADMIN_KEYTAB,"/tmp/keyadmin.keytab",)
def _kinit_with_retry(principal: str, keytab: str, ensure_keytab_fn, max_attempts: int = 5, wait: int = 10) -> None:
"""Ensure the keytab exists then kinit, retrying to absorb KDC warm-up delays."""
kms = get_kms_container()
exit_code, _ = kms.exec_run(f"test -f {keytab}", user="root")
if exit_code != 0:
ensure_keytab_fn()
kms.exec_run("kdestroy -A 2>/dev/null || true", user="root")
for _ in range(max_attempts):
exit_code, output = kms.exec_run(
f"kinit -kt {keytab} {principal}", user="root"
)
if exit_code == 0:
return
time.sleep(wait)
raise RuntimeError(f"kinit failed for {principal} after {max_attempts} attempts: {output.decode()}")
def ensure_ticket():
"""Obtain a keyadmin Kerberos ticket (backward-compat alias for ensure_keyadmin_ticket)."""
_kinit_with_retry(KEYADMIN_PRINCIPAL, KEYADMIN_KEYTAB, ensure_keyadmin_keytab)
def ensure_keyadmin_ticket():
"""Obtain a keyadmin Kerberos ticket."""
_kinit_with_retry(KEYADMIN_PRINCIPAL, KEYADMIN_KEYTAB, ensure_keyadmin_keytab)
def ensure_testuser_keytab():
_ensure_kdc_principal_keytab(
TESTUSER_PRINCIPAL,
KMS_CONTAINER_NAME,
TESTUSER_KEYTAB,
f"/tmp/{TESTUSER}.keytab",
)
def ensure_testuser_ticket():
"""Obtain a testuser Kerberos ticket."""
_kinit_with_retry(TESTUSER_PRINCIPAL, TESTUSER_KEYTAB, ensure_testuser_keytab)
def modify_blacklist_property(operation, users, action="add"):
dbks_site_path = (
"/opt/ranger/ranger-3.0.0-SNAPSHOT-kms/ews/webapp"
"/WEB-INF/classes/conf/dbks-site.xml"
)
ensure_ticket()
result = get_kms_container().exec_run(f"cat {dbks_site_path}", user="root")
if result.exit_code != 0:
raise RuntimeError(f"Cannot read dbks-site.xml: {result.output.decode()}")
root = ET.fromstring(result.output.decode("utf-8"))
prop_name = f"hadoop.kms.blacklist.{operation}"
prop = None
for elem in root.findall("property"):
name = elem.find("name")
if name is not None and name.text == prop_name:
prop = elem
break
if prop is None:
prop = ET.SubElement(root, "property")
ET.SubElement(prop, "name").text = prop_name
ET.SubElement(prop, "value").text = ""
val_elem = prop.find("value")
current = val_elem.text.split(",") if val_elem.text else []
updated = set(current)
if action == "add":
updated.update(users)
elif action == "remove":
updated -= set(users)
val_elem.text = ",".join(sorted(updated))
modified_xml = ET.tostring(root, encoding="utf-8", method="xml").decode()
tarstream = io.BytesIO()
with tarfile.open(fileobj=tarstream, mode="w") as tar:
data = modified_xml.encode()
info = tarfile.TarInfo(name="dbks-site.xml")
info.size = len(data)
tar.addfile(info, io.BytesIO(data))
tarstream.seek(0)
get_kms_container().put_archive(
path="/opt/ranger/ranger-3.0.0-SNAPSHOT-kms/ews/webapp/WEB-INF/classes/conf/",
data=tarstream,
)
print(f"Successfully {'added' if action == 'add' else 'removed'} {users} in {prop_name}")
def blacklist_op_users(operation, users=[]):
modify_blacklist_property(operation, users, action="add")
def unblacklist_op_users(operation, users=[]):
modify_blacklist_property(operation, users, action="remove")
def ensure_test_user_exists(username: str) -> None:
payload = {
"name": username,
"firstName": "Test",
"lastName": "User",
"password": "Password123!",
"description": "pytest dummy user created via API",
"status": 1,
"isVisible": 1,
"userSource": 0,
"userRoleList": ["ROLE_USER"],
}
r = requests.post(BASE_URL_RANGER_USERS, auth=RANGER_ADMIN_AUTH, json=payload)
if r.status_code in (200, 201):
return
raise RuntimeError(f"Failed to create Ranger user {username}: {r.status_code} {r.text}")
def delete_test_user(username: str) -> None:
r = requests.delete(
f"{BASE_URL_RANGER_USERS_BY_NAME}/{username}",
params={"forceDelete": "true"},
auth=RANGER_ADMIN_AUTH,
)
if r.status_code in (200, 204, 404):
return
raise RuntimeError(f"Failed to delete Ranger user {username}: {r.status_code} {r.text}")