blob: e9a85746e8e75968cddfb6fbec2bd5352f81f12a [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 fcntl
import hashlib
import json
import os
import platform
import re
import shutil
import tarfile
import tempfile
from pathlib import Path
from managed_runtime import directory, download, no_links
from observations import atomic_json
ROOT = Path("/opt/ambari-mpacks")
CACHE = Path("/var/cache/ambari-mpacks")
def require_platform():
if platform.system() != "Linux" or platform.machine() not in {"aarch64", "arm64"}:
raise ValueError("This distribution requires Linux ARM64")
def digest_file(path, algorithm):
digest = hashlib.new(algorithm)
with Path(path).open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def _inside(root, path):
try:
path.relative_to(root)
except ValueError:
raise ValueError("Archive member leaves its distribution")
def extract(archive, target, prefix, maximum_expanded=16 * 1024 ** 3):
target = Path(target)
no_links(target)
target.mkdir(mode=0o755)
pending_links = []
seen = set()
expanded = 0
with tarfile.open(str(archive), "r:gz") as source:
for entry in source:
parts = Path(entry.name).parts
if not parts or parts[0] != prefix or ".." in parts or Path(entry.name).is_absolute():
raise ValueError("Archive member leaves its pinned distribution")
relative = Path(*parts[1:])
if relative == Path("."):
if not entry.isdir() or entry.size:
raise ValueError("Invalid distribution root")
continue
if len(seen) >= 250000 or str(relative) in seen:
raise ValueError("Duplicate or excessive archive members")
seen.add(str(relative))
destination = target / relative
no_links(destination)
if entry.isdir() and entry.size == 0:
destination.mkdir(parents=True, exist_ok=True)
destination.chmod(0o755)
elif entry.isfile() and not entry.issparse():
expanded += entry.size
if expanded > maximum_expanded:
raise ValueError("Expanded distribution exceeds its limit")
destination.parent.mkdir(parents=True, exist_ok=True)
with source.extractfile(entry) as incoming, destination.open("xb") as outgoing:
shutil.copyfileobj(incoming, outgoing)
destination.chmod(0o755 if entry.mode & 0o111 else 0o644)
elif (entry.issym() or entry.islnk()) and entry.size == 0:
pending_links.append((destination, entry.linkname, entry.issym()))
else:
raise ValueError("Unsupported distribution member")
for destination, link, symbolic in pending_links:
link_path = Path(link)
if link_path.is_absolute() or ".." in link_path.parts and not symbolic:
raise ValueError("Invalid distribution link")
candidate = destination.parent / link if symbolic else target / link_path.relative_to(prefix)
resolved = candidate.resolve()
_inside(target.resolve(), resolved)
if not resolved.is_file() and not resolved.is_dir():
raise ValueError("Distribution link target is missing")
destination.parent.mkdir(parents=True, exist_ok=True)
if symbolic:
destination.symlink_to(link)
elif resolved.is_file():
os.link(str(resolved), str(destination))
else:
raise ValueError("Hard link does not point to a file")
return len(seen)
def verify_install(home, name, version, archive_sha512, required):
home = Path(home)
no_links(home)
marker = home / ".ambari-receipt.json"
no_links(marker)
receipt = json.loads(marker.read_text())
identity = {"schema_version": 1, "name": name, "version": version,
"archive_sha512": archive_sha512}
if any(type(receipt.get(key)) is not type(value) or receipt[key] != value
for key, value in identity.items()):
raise ValueError("Installed distribution identity differs")
hashes = receipt.get("required_sha256")
if not isinstance(hashes, dict) or set(hashes) != set(required):
raise ValueError("Missing required distribution identity")
for relative in required:
path = home / relative
no_links(path)
if not path.is_file() or not isinstance(hashes[relative], str) or not re.fullmatch(r"[0-9a-f]{64}", hashes[relative]):
raise ValueError("Required distribution file is missing")
if digest_file(path, "sha256") != hashes[relative]:
raise ValueError("Installed distribution file differs from its receipt")
return {"name": name, "version": version, "archive_sha512": archive_sha512,
"verified_files": len(required)}
def install_archive(name, version, url, archive_sha512, prefix, required,
maximum_archive=6 * 1024 ** 3, maximum_expanded=16 * 1024 ** 3):
require_platform()
if not re.fullmatch(r"[a-z][a-z0-9-]*", name) or not re.fullmatch(r"[A-Za-z0-9._-]+", version):
raise ValueError("Invalid distribution identity")
if not re.fullmatch(r"[0-9a-f]{128}", archive_sha512):
raise ValueError("Invalid pinned archive digest")
if not required or any(Path(path).is_absolute() or ".." in Path(path).parts
or Path(path) == Path(".") for path in required):
raise ValueError("Invalid required file manifest")
home = ROOT / name / version
directory(home.parent, "root", 0o755)
lock = home.parent / ".install.lock"
no_links(lock)
descriptor = os.open(str(lock), os.O_WRONLY | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
try:
fcntl.flock(descriptor, fcntl.LOCK_EX)
if home.exists():
return verify_install(home, name, version, archive_sha512, required)
return _install_locked(home, name, version, url, archive_sha512, prefix,
required, maximum_archive, maximum_expanded)
finally:
fcntl.flock(descriptor, fcntl.LOCK_UN)
os.close(descriptor)
def _install_locked(home, name, version, url, archive_sha512, prefix, required,
maximum_archive, maximum_expanded):
cache = directory(CACHE, "root", 0o755) / (name + "-" + version + ".tar.gz")
no_links(cache)
with tempfile.TemporaryDirectory(prefix=".install-", dir=str(home.parent)) as work:
archive = cache
if cache.exists():
if digest_file(cache, "sha512") != archive_sha512:
raise ValueError("Cached distribution differs from its pinned digest")
else:
archive = Path(work) / "distribution.tar.gz"
download(url, archive, archive_sha512, maximum=maximum_archive)
target = Path(work) / "distribution"
extract(archive, target, prefix, maximum_expanded)
hashes = {}
for relative in required:
path = target / relative
no_links(path)
if not path.is_file():
raise ValueError("Pinned distribution lacks a required file")
hashes[relative] = digest_file(path, "sha256")
atomic_json(target / ".ambari-receipt.json", {"schema_version": 1, "name": name,
"version": version, "archive_sha512": archive_sha512,
"required_sha256": hashes})
os.rename(str(target), str(home))
return verify_install(home, name, version, archive_sha512, required)