blob: 51c3608b41c18af2bb27696344dc1b91eeffdf98 [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 hashlib
import json
import os
import pwd
import re
import subprocess
import tempfile
import urllib.parse
import urllib.request
from pathlib import Path
from observations import atomic_json, unit_observation, validate_request
IDENTITY = ("schema_version", "definition_snapshot", "execution_id", "task_id", "action")
def resolve_topology(service, parameters, topology):
value = dict(parameters)
fields = {"airflow": {"server_host": ("airflow_server_hosts", True)},
"celeborn": {"master_host": ("celeborn_master_hosts", True),
"worker_hosts": ("celeborn_worker_hosts", False)}}[service]
for field, (key, single) in fields.items():
if value.get(field):
continue
hosts = topology.get(key)
if not isinstance(hosts, (list, tuple)) or not hosts or (single and len(hosts) != 1):
raise ValueError("Missing or ambiguous component assignment: " + key)
hosts = sorted(set(host_value(host) for host in hosts))
value[field] = hosts[0] if single else ",".join(hosts)
return value
def result(request, outcome, observed):
# Configuration can contain credentials; never publish the request parameters.
return dict({key: request[key] for key in IDENTITY}, outcome=outcome, observations=observed)
def validate_result(request, value):
if not isinstance(value, dict):
raise ValueError("Missing structured result")
for key in IDENTITY:
if type(value.get(key)) is not type(request[key]) or value.get(key) != request[key]:
raise ValueError("Missing or foreign execution identity")
if value.get("outcome") not in {"APPLIED", "UNRESOLVED"} or not isinstance(value.get("observations"), dict) or not value["observations"]:
raise ValueError("Invalid observation contract")
return value
def path_value(value, roots=("/var/lib/", "/opt/", "/usr/lib/jvm/")):
if (not isinstance(value, str) or not re.fullmatch(r"/[A-Za-z0-9_./-]+", value)
or ".." in Path(value).parts or not any(value.startswith(root) for root in roots)):
raise ValueError("Invalid managed runtime path")
return Path(value)
def host_value(value):
if not isinstance(value, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.-]*", value):
raise ValueError("An explicit service hostname is required")
return value
def port_value(value):
if not isinstance(value, str) or not re.fullmatch(r"[1-9][0-9]{0,4}", value) or int(value) > 65535:
raise ValueError("Invalid service port")
return int(value)
def no_links(path):
for parent in (path,) + tuple(path.parents):
if parent.is_symlink():
raise ValueError("Managed paths must not traverse symbolic links")
def directory(path, user, mode=0o750):
path = Path(path)
no_links(path)
path.mkdir(parents=True, exist_ok=True, mode=mode)
account = pwd.getpwnam(user)
os.chown(str(path), account.pw_uid, account.pw_gid)
os.chmod(str(path), mode)
return path
def owned_file(path, content, user="root", mode=0o640):
path = Path(path)
no_links(path)
account = pwd.getpwnam(user)
descriptor, temporary = tempfile.mkstemp(prefix=".ambari-", dir=str(path.parent))
try:
with os.fdopen(descriptor, "wb") as stream:
os.fchmod(stream.fileno(), mode)
os.fchown(stream.fileno(), account.pw_uid, account.pw_gid)
stream.write(content.encode("utf-8"))
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, str(path))
finally:
if os.path.exists(temporary):
os.unlink(temporary)
def run(command, timeout=600, env=None):
completed = subprocess.run(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=timeout, env=env, check=False)
if completed.returncode:
raise RuntimeError("Runtime command failed; inspect the service journal")
return completed
def reload_units():
import dbus
bus = dbus.SystemBus()
try:
manager = dbus.Interface(bus.get_object("org.freedesktop.systemd1", "/org/freedesktop/systemd1"),
"org.freedesktop.systemd1.Manager")
manager.Reload()
finally:
bus.close()
def unit(name, user, executable, environment, working_directory, arguments=""):
for value in (name, user):
if not re.fullmatch(r"[A-Za-z0-9_.-]+", value):
raise ValueError("Invalid systemd identity")
lines = ["[Unit]", "Description=Ambari managed " + name, "After=network-online.target",
"[Service]", "Type=simple", "User=" + user, "Group=" + user,
"WorkingDirectory=" + str(working_directory)]
for key, value in sorted(environment.items()):
if not re.fullmatch(r"[A-Z][A-Z0-9_]*", key) or any(c in str(value) for c in '\r\n\x00"\\%'):
raise ValueError("Invalid systemd environment")
lines.append('Environment="' + key + '=' + str(value) + '"')
if not re.fullmatch(r"/[A-Za-z0-9_./-]+", str(executable)) or any(c in arguments for c in '\r\n\x00"%'):
raise ValueError("Invalid service command")
lines.extend(["ExecStart=" + str(executable) + (" " + arguments if arguments else ""),
"Restart=no", "TimeoutStopSec=90", "KillMode=control-group",
"SuccessExitStatus=143", "UMask=0027", ""])
owned_file(Path("/etc/systemd/system") / name, "\n".join(lines))
def json_http(url, data=None, headers=None):
request = urllib.request.Request(url, data=None if data is None else json.dumps(data).encode(),
headers=dict({"Accept": "application/json", "Content-Type": "application/json"}, **(headers or {})))
# Do not send credentials or observe an unexpected endpoint via a redirect.
class NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args):
return None
with urllib.request.build_opener(NoRedirect()).open(request, timeout=15) as response:
payload = response.read(1024 * 1024 + 1)
if response.status not in {200, 201} or len(payload) > 1024 * 1024:
raise ValueError("Invalid bounded API response")
value = json.loads(payload)
if not isinstance(value, dict):
raise ValueError("Expected an API object")
return value
def download(url, target, expected, algorithm="sha512", maximum=1024 * 1024 * 1024):
if not isinstance(url, str) or urllib.parse.urlsplit(url).scheme != "https":
raise ValueError("Software downloads require HTTPS")
parsed = urllib.parse.urlsplit(url)
if parsed.username or parsed.password or parsed.fragment:
raise ValueError("Download URL must not contain credentials or fragments")
digest = hashlib.new(algorithm)
count = 0
with urllib.request.urlopen(url, timeout=60) as response, Path(target).open("xb") as stream:
if urllib.parse.urlsplit(response.url).scheme != "https":
raise ValueError("Download redirected outside HTTPS")
while True:
block = response.read(1024 * 1024)
if not block:
break
count += len(block)
if count > maximum:
raise ValueError("Software download exceeds its limit")
stream.write(block)
digest.update(block)
stream.flush()
os.fsync(stream.fileno())
if digest.hexdigest() != expected:
raise ValueError("Software download differs from its pinned digest")
def main(execute):
import sys
request = validate_request(json.loads(Path(sys.argv[1]).read_text()))
try:
observed = execute(request)
value = result(request, "APPLIED", observed)
except Exception as error:
from native_config import ConfigurationError
observation = {"error_type": type(error).__name__}
if isinstance(error, ConfigurationError):
observation["configuration_error"] = str(error)
value = result(request, "UNRESOLVED", observation)
atomic_json(sys.argv[2], value)
raise SystemExit(1)
atomic_json(sys.argv[2], value)