Rework requirement checks to integrate architecture, multiinnstance, disk/ram, ... + drop disk/ram as resource, have them directly in 'integration'

This commit is contained in:
Alexandre Aubin 2021-11-03 18:33:00 +01:00
parent eb14a2220f
commit 6a437c0b4f
3 changed files with 79 additions and 118 deletions

View file

@ -35,6 +35,7 @@ import tempfile
import copy import copy
from collections import OrderedDict from collections import OrderedDict
from typing import List, Tuple, Dict, Any from typing import List, Tuple, Dict, Any
from packaging import version
from moulinette import Moulinette, m18n from moulinette import Moulinette, m18n
from moulinette.utils.log import getActionLogger from moulinette.utils.log import getActionLogger
@ -52,7 +53,7 @@ from moulinette.utils.filesystem import (
chmod, chmod,
) )
from yunohost.utils import packages from yunohost.utils.packages import dpkg_is_broken, get_ynh_package_version
from yunohost.utils.config import ( from yunohost.utils.config import (
ConfigPanel, ConfigPanel,
ask_questions_and_parse_answers, ask_questions_and_parse_answers,
@ -194,7 +195,6 @@ def app_info(app, full=False):
def _app_upgradable(app_infos): def _app_upgradable(app_infos):
from packaging import version
# Determine upgradability # Determine upgradability
@ -460,7 +460,6 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
no_safety_backup -- Disable the safety backup during upgrade no_safety_backup -- Disable the safety backup during upgrade
""" """
from packaging import version
from yunohost.hook import ( from yunohost.hook import (
hook_add, hook_add,
hook_remove, hook_remove,
@ -557,7 +556,7 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
upgrade_type = "UPGRADE_FULL" upgrade_type = "UPGRADE_FULL"
# Check requirements # Check requirements
_check_manifest_requirements(manifest) _check_manifest_requirements(manifest, action="upgrade")
if manifest["packaging_format"] >= 2: if manifest["packaging_format"] >= 2:
if no_safety_backup: if no_safety_backup:
@ -814,15 +813,12 @@ def app_install(
label = label if label else manifest["name"] label = label if label else manifest["name"]
# Check requirements # Check requirements
_check_manifest_requirements(manifest) _check_manifest_requirements(manifest, action="install")
_assert_system_is_sane_for_app(manifest, "pre") _assert_system_is_sane_for_app(manifest, "pre")
# Check if app can be forked # Check if app can be forked
instance_number = _next_instance_number_for_app(app_id) instance_number = _next_instance_number_for_app(app_id)
if instance_number > 1: if instance_number > 1:
if "multi_instance" not in manifest or not is_true(manifest["multi_instance"]):
raise YunohostValidationError("app_already_installed", app=app_id)
# Change app_id to the forked app id # Change app_id to the forked app id
app_instance_name = app_id + "__" + str(instance_number) app_instance_name = app_id + "__" + str(instance_number)
else: else:
@ -1995,11 +1991,13 @@ def _convert_v1_manifest_to_v2(manifest):
manifest["upstream"]["website"] = manifest["url"] manifest["upstream"]["website"] = manifest["url"]
manifest["integration"] = { manifest["integration"] = {
"yunohost": manifest.get("requirements", {}).get("yunohost"), "yunohost": manifest.get("requirements", {}).get("yunohost", "").replace(">", "").replace("=", "").replace(" ", ""),
"architectures": "all", "architectures": "all",
"multi_instance": manifest.get("multi_instance", False), "multi_instance": is_true(manifest.get("multi_instance", False)),
"ldap": "?", "ldap": "?",
"sso": "?", "sso": "?",
"disk": "50M",
"ram": {"build": "50M", "runtime": "10M", "include_swap": False}
} }
maintainer = manifest.get("maintainer", {}).get("name") maintainer = manifest.get("maintainer", {}).get("name")
@ -2017,29 +2015,12 @@ def _convert_v1_manifest_to_v2(manifest):
manifest["install"][name] = question manifest["install"][name] = question
manifest["resources"] = { manifest["resources"] = {
"disk": { "system_user": {},
"build": "50M", # This is an *estimate* minimum value for the disk needed at build time (e.g. during install/upgrade) and during regular usage
"usage": "50M" # Please only use round values such as: 10M, 100M, 200M, 400M, 800M, 1G, 2G, 4G, 8G
},
"ram": {
"build": "50M", # This is an *estimate* minimum value for the RAM needed at build time (i.e. during install/upgrade) and during regular usage
"usage": "10M", # Please only use round values like ["10M", "100M", "200M", "400M", "800M", "1G", "2G", "4G", "8G"]
"include_swap": False
},
"route": {},
"install_dir": { "install_dir": {
"base_dir": "/var/www/", # This means that the app shall be installed in /var/www/$app which is the standard for webapps. You may change this to /opt/ if the app is a system app.
"alias": "final_path" "alias": "final_path"
} }
} }
if "domain" in manifest["install"] and "path" in manifest["install"]:
manifest["resources"]["route"]["url"] = "{domain}{path}"
elif "path" not in manifest["install"]:
manifest["resources"]["route"]["url"] = "{domain}/"
else:
del manifest["resources"]["route"]
keys_to_keep = ["packaging_format", "id", "name", "description", "version", "maintainers", "upstream", "integration", "install", "resources"] keys_to_keep = ["packaging_format", "id", "name", "description", "version", "maintainers", "upstream", "integration", "install", "resources"]
keys_to_del = [key for key in manifest.keys() if key not in keys_to_keep] keys_to_del = [key for key in manifest.keys() if key not in keys_to_keep]
@ -2325,32 +2306,60 @@ def _get_all_installed_apps_id():
return all_apps_ids_formatted return all_apps_ids_formatted
def _check_manifest_requirements(manifest: Dict): def _check_manifest_requirements(manifest: Dict, action: str):
"""Check if required packages are met from the manifest""" """Check if required packages are met from the manifest"""
if manifest["packaging_format"] not in [1, 2]: if manifest["packaging_format"] not in [1, 2]:
raise YunohostValidationError("app_packaging_format_not_supported") raise YunohostValidationError("app_packaging_format_not_supported")
requirements = manifest.get("requirements", dict()) app_id = manifest["id"]
if not requirements:
return
app = manifest.get("id", "?")
logger.debug(m18n.n("app_requirements_checking", app=app)) logger.debug(m18n.n("app_requirements_checking", app=app))
# Iterate over requirements # Yunohost version requirement
for pkgname, spec in requirements.items():
if not packages.meets_version_specifier(pkgname, spec): yunohost_requirement = version.parse(manifest["integration"]["yunohost"] or "4.3")
version = packages.ynh_packages_version()[pkgname]["version"] yunohost_installed_version = get_ynh_package_version("yunohost")["version"]
raise YunohostValidationError( if yunohost_requirement > yunohost_installed_version:
"app_requirements_unmeet", # FIXME : i18n
pkgname=pkgname, raise YunohostValidationError(f"This app requires Yunohost >= {yunohost_requirement} but current installed version is {yunohost_installed_version}")
version=version,
spec=spec, # Architectures
app=app, arch_requirement = manifest["integration"]["architectures"]
) if arch_requirement != "all":
arch = check_output("dpkg --print-architecture")
if arch not in arch_requirement:
# FIXME: i18n
raise YunohostValidationError(f"This app can only be installed on architectures {', '.join(arch_requirement)} but your server architecture is {arch}")
# Multi-instance
if action == "install" and manifest["integration"]["multi_instance"] == False:
apps = _installed_apps()
sibling_apps = [a for a in apps if a == app_id or a.startswith(f"{app_id}__")]
if len(sibling_apps) > 0:
raise YunohostValidationError("app_already_installed", app=app_id)
# Disk
if action == "install":
disk_requirement = manifest["integration"]["disk"]
if free_space_in_directory("/") <= human_to_binary[disk_requirement] \
or free_space_in_directory("/var") <= human_to_binary[disk_requirement]:
# FIXME : i18m
raise YunohostValidationError("This app requires {disk_requirement} free space.")
# Ram for build
import psutil
ram_build_requirement = manifest["integration"]["ram"]["build"]
ram_include_swap = manifest["integration"]["ram"]["include_swap"]
ram_available = psutil.virtual_memory().available
if ram_include_swap:
ram_available += psutil.swap_memory().available
if ram_available < human_to_binary(ram_build_requirement):
# FIXME : i18n
raise YunohostValidationError("This app requires {ram_build_requirement} RAM available to install/upgrade")
def _guess_webapp_path_requirement(app_folder: str) -> str: def _guess_webapp_path_requirement(app_folder: str) -> str:
@ -2688,8 +2697,30 @@ def _assert_system_is_sane_for_app(manifest, when):
"app_action_broke_system", services=", ".join(faulty_services) "app_action_broke_system", services=", ".join(faulty_services)
) )
if packages.dpkg_is_broken(): if dpkg_is_broken():
if when == "pre": if when == "pre":
raise YunohostValidationError("dpkg_is_broken") raise YunohostValidationError("dpkg_is_broken")
elif when == "post": elif when == "post":
raise YunohostError("this_action_broke_dpkg") raise YunohostError("this_action_broke_dpkg")
def human_to_binary(size: str) -> int:
symbols = ("K", "M", "G", "T", "P", "E", "Z", "Y")
factor = {}
for i, s in enumerate(symbols):
factor[s] = 1 << (i + 1) * 10
suffix = size[-1]
size = size[:-1]
if suffix not in symbols:
raise YunohostError(f"Invalid size suffix '{suffix}', expected one of {symbols}")
try:
size = float(size)
except Exception:
raise YunohostError(f"Failed to convert size {size} to float")
return size * factor[suffix]

View file

@ -2695,3 +2695,4 @@ def binary_to_human(n, customary=False):
value = float(n) / prefix[s] value = float(n) / prefix[s]
return "%.1f%s" % (value, s) return "%.1f%s" % (value, s)
return "%s" % n return "%s" % n

View file

@ -20,11 +20,9 @@
""" """
import os import os
import copy import copy
import psutil
from typing import Dict, Any from typing import Dict, Any
from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.utils.filesystem import free_space_in_directory
class AppResource(object): class AppResource(object):
@ -60,75 +58,6 @@ class AppResourceSet:
resource.check_availability(context={}) resource.check_availability(context={})
M = 1024 ** 2
G = 1024 * M
sizes = {
"10M": 10 * M,
"20M": 20 * M,
"40M": 40 * M,
"80M": 80 * M,
"100M": 100 * M,
"200M": 200 * M,
"400M": 400 * M,
"800M": 800 * M,
"1G": 1 * G,
"2G": 2 * G,
"4G": 4 * G,
"8G": 8 * G,
"10G": 10 * G,
"20G": 20 * G,
"40G": 40 * G,
"80G": 80 * G,
}
class DiskAppResource(AppResource):
type = "disk"
default_properties = {
"space": "10M",
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# FIXME: better error handling
assert self.space in sizes
def assert_availability(self, context: Dict):
if free_space_in_directory("/") <= sizes[self.space] \
or free_space_in_directory("/var") <= sizes[self.space]:
raise YunohostValidationError("Not enough disk space") # FIXME: i18n / better messaging
class RamAppResource(AppResource):
type = "ram"
default_properties = {
"build": "10M",
"runtime": "10M",
"include_swap": False
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# FIXME: better error handling
assert self.build in sizes
assert self.runtime in sizes
assert isinstance(self.include_swap, bool)
def assert_availability(self, context: Dict):
memory = psutil.virtual_memory().available
if self.include_swap:
memory += psutil.swap_memory().available
max_size = max(sizes[self.build], sizes[self.runtime])
if memory <= max_size:
raise YunohostValidationError("Not enough RAM/swap") # FIXME: i18n / better messaging
class AptDependenciesAppResource(AppResource): class AptDependenciesAppResource(AppResource):
type = "apt" type = "apt"