diff --git a/src/yunohost/app.py b/src/yunohost/app.py index 05db1fa56..8e05c37c9 100644 --- a/src/yunohost/app.py +++ b/src/yunohost/app.py @@ -59,6 +59,7 @@ from yunohost.utils.config import ( DomainQuestion, PathQuestion, ) +from yunohost.utils.resources import AppResourceSet from yunohost.utils.i18n import _value_for_locale from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.filesystem import free_space_in_directory @@ -803,6 +804,7 @@ def app_install( confirm_install(app) manifest, extracted_app_folder = _extract_app(app) + packaging_format = manifest["packaging_format"] # Check ID if "id" not in manifest or "__" in manifest["id"] or "." in manifest["id"]: @@ -827,7 +829,7 @@ def app_install( app_instance_name = app_id # Retrieve arguments list for install script - raw_questions = manifest.get("arguments", {}).get("install", {}) + raw_questions = manifest["install"] questions = ask_questions_and_parse_answers(raw_questions, prefilled_answers=args) args = { question.name: question.value @@ -836,11 +838,12 @@ def app_install( } # Validate domain / path availability for webapps - path_requirement = _guess_webapp_path_requirement(extracted_app_folder) - _validate_webpath_requirement(args, path_requirement) + if packaging_format < 2: + path_requirement = _guess_webapp_path_requirement(extracted_app_folder) + _validate_webpath_requirement(args, path_requirement) - # Attempt to patch legacy helpers ... - _patch_legacy_helpers(extracted_app_folder) + # Attempt to patch legacy helpers ... + _patch_legacy_helpers(extracted_app_folder) # Apply dirty patch to make php5 apps compatible with php7 _patch_legacy_php_versions(extracted_app_folder) @@ -870,7 +873,6 @@ def app_install( } # If packaging_format v2+, save all install questions as settings - packaging_format = int(manifest.get("packaging_format", 0)) if packaging_format >= 2: for arg_name, arg_value in args.items(): @@ -891,17 +893,23 @@ def app_install( recursive=True, ) - # Initialize the main permission for the app - # The permission is initialized with no url associated, and with tile disabled - # For web app, the root path of the app will be added as url and the tile - # will be enabled during the app install. C.f. 'app_register_url()' below. - permission_create( - app_instance_name + ".main", - allowed=["all_users"], - label=label, - show_tile=False, - protected=False, - ) + + resources = AppResourceSet(manifest["resources"], app_instance_name) + resources.check_availability() + resources.provision() + + if packaging_format < 2: + # Initialize the main permission for the app + # The permission is initialized with no url associated, and with tile disabled + # For web app, the root path of the app will be added as url and the tile + # will be enabled during the app install. C.f. 'app_register_url()' below. + permission_create( + app_instance_name + ".main", + allowed=["all_users"], + label=label, + show_tile=False, + protected=False, + ) # Prepare env. var. to pass to script env_dict = _make_environment_for_app_script( @@ -2354,13 +2362,13 @@ def _guess_webapp_path_requirement(app_folder: str) -> str: # is an available url and normalize the path. manifest = _get_manifest_of_app(app_folder) - raw_questions = manifest.get("arguments", {}).get("install", {}) + raw_questions = manifest["install"] domain_questions = [ - question for question in raw_questions if question.get("type") == "domain" + question for question in raw_questions.values() if question.get("type") == "domain" ] path_questions = [ - question for question in raw_questions if question.get("type") == "path" + question for question in raw_questions.values() if question.get("type") == "path" ] if len(domain_questions) == 0 and len(path_questions) == 0: diff --git a/src/yunohost/utils/config.py b/src/yunohost/utils/config.py index 4ee62c6f7..b7bd4e723 100644 --- a/src/yunohost/utils/config.py +++ b/src/yunohost/utils/config.py @@ -1052,6 +1052,21 @@ class UserQuestion(Question): break +class GroupQuestion(Question): + argument_type = "group" + + def __init__(self, question, context: Mapping[str, Any] = {}): + + from yunohost.user import user_group_list + + super().__init__(question, context) + + self.choices = list(user_group_list(short=True)["groups"]) + + if self.default is None: + self.default = "all_users" + + class NumberQuestion(Question): argument_type = "number" default_value = None @@ -1204,6 +1219,7 @@ ARGUMENTS_TYPE_PARSERS = { "boolean": BooleanQuestion, "domain": DomainQuestion, "user": UserQuestion, + "group": GroupQuestion, "number": NumberQuestion, "range": NumberQuestion, "display_text": DisplayTextQuestion, @@ -1243,9 +1259,9 @@ def ask_questions_and_parse_answers( out = [] - for raw_question in raw_questions: + for name, raw_question in raw_questions.items(): question_class = ARGUMENTS_TYPE_PARSERS[raw_question.get("type", "string")] - raw_question["value"] = answers.get(raw_question["name"]) + raw_question["value"] = answers.get(name) question = question_class(raw_question, context=answers) answers[question.name] = question.ask_if_needed() out.append(question) diff --git a/src/yunohost/utils/resources.py b/src/yunohost/utils/resources.py index 42ae94d83..c71af09b8 100644 --- a/src/yunohost/utils/resources.py +++ b/src/yunohost/utils/resources.py @@ -27,15 +27,37 @@ from yunohost.utils.error import YunohostError, YunohostValidationError from yunohost.utils.filesystem import free_space_in_directory -class AppResource: +class AppResource(object): - def __init__(self, properties: Dict[str, Any], app_id: str, app_settings): + def __init__(self, properties: Dict[str, Any], app_id: str): + + self.app_id = app_id for key, value in self.default_properties.items(): setattr(self, key, value) - for key, value in properties: - setattr(self. key, value) + for key, value in properties.items(): + setattr(self, key, value) + + def get_app_settings(self): + from yunohost.app import _get_app_settings + return _get_app_settings(self.app_id) + + def check_availability(self, context: Dict): + pass + + +class AppResourceSet: + + def __init__(self, resources_dict: Dict[str, Dict[str, Any]], app_id: str): + + self.set = {name: AppResourceClassesByType[name](infos, app_id) + for name, infos in resources_dict.items()} + + def check_availability(self): + + for name, resource in self.set.items(): + resource.check_availability(context={}) M = 1024 ** 2 @@ -68,19 +90,16 @@ class DiskAppResource(AppResource): } def __init__(self, *args, **kwargs): - super().__init__(self, *args, **kwargs) + super().__init__(*args, **kwargs) # FIXME: better error handling assert self.space in sizes - def provision_or_update(self, context: Dict): + 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 - def deprovision(self, context: Dict): - pass - class RamAppResource(AppResource): type = "ram" @@ -92,13 +111,13 @@ class RamAppResource(AppResource): } def __init__(self, *args, **kwargs): - super().__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 provision_or_update(self, context: Dict): + def assert_availability(self, context: Dict): memory = psutil.virtual_memory().available if self.include_swap: @@ -109,37 +128,78 @@ class RamAppResource(AppResource): if memory <= max_size: raise YunohostValidationError("Not enough RAM/swap") # FIXME: i18n / better messaging - def deprovision(self, context: Dict): + +class AptDependenciesAppResource(AppResource): + type = "apt" + + default_properties = { + "packages": [], + "extras": {} + } + + def check_availability(self, context): + # ? FIXME + # call helpers idk ... + pass + +class SourcesAppResource(AppResource): + type = "sources" + + default_properties = { + "main": {"url": "?", "sha256sum": "?", "predownload": True} + } + + def check_availability(self, context): + # ? FIXME + # call request.head on the url idk pass -class WebpathAppResource(AppResource): - type = "webpath" +class RoutesAppResource(AppResource): + type = "routes" default_properties = { - "url": "__DOMAIN____PATH__" + "full_domain": False, + "main": { + "url": "/", + "additional_urls": [], + "init_allowed": "__FIXME__", + "show_tile": True, + "protected": False, + "auth_header": True, + "label": "FIXME", + } } - def provision_or_update(self, context: Dict): + def check_availability(self, context): from yunohost.app import _assert_no_conflicting_apps - # Check the url is available - domain = context["app_settings"]["domain"] - path = context["app_settings"]["path"] or "/" - _assert_no_conflicting_apps(domain, path, ignore_app=context["app"]) - context["app_settings"]["path"] = path + app_settings = self.get_app_settings() + domain = app_settings["domain"] + path = app_settings["path"] if not self.full_domain else "/" + _assert_no_conflicting_apps(domain, path, ignore_app=self.app_id) + + def provision_or_update(self, context: Dict): if context["app_action"] == "install": + pass # FIXME # Initially, the .main permission is created with no url at all associated # When the app register/books its web url, we also add the url '/' # (meaning the root of the app, domain.tld/path/) # and enable the tile to the SSO, and both of this should match 95% of apps # For more specific cases, the app is free to change / add urls or disable # the tile using the permission helpers. - permission_url(app + ".main", url="/", sync_perm=False) - user_permission_update(app + ".main", show_tile=True, sync_perm=False) - permission_sync_to_user() + #permission_create( + # self.app_id + ".main", + # allowed=["all_users"], + # label=label, + # show_tile=False, + # protected=False, + #) + #permission_url(app + ".main", url="/", sync_perm=False) + #user_permission_update(app + ".main", show_tile=True, sync_perm=False) + #permission_sync_to_user() def deprovision(self, context: Dict): del context["app_settings"]["domain"] @@ -177,8 +237,8 @@ class PortAppResource(AppResource): raise NotImplementedError() -class UserAppResource(AppResource): - type = "user" +class SystemuserAppResource(AppResource): + type = "system_user" default_properties = { "username": "__APP__", @@ -187,6 +247,12 @@ class UserAppResource(AppResource): "groups": [] } + def check_availability(self, context): + if os.system(f"getent passwd {self.username} &>/dev/null") != 0: + raise YunohostValidationError(f"User {self.username} already exists") + if os.system(f"getent group {self.username} &>/dev/null") != 0: + raise YunohostValidationError(f"Group {self.username} already exists") + def provision_or_update(self, context: str): raise NotImplementedError() @@ -195,13 +261,19 @@ class UserAppResource(AppResource): class InstalldirAppResource(AppResource): - type = "installdir" + type = "install_dir" default_properties = { - "dir": "/var/www/__APP__", + "dir": "/var/www/__APP__", # FIXME or choose to move this elsewhere nowadays idk... "alias": "final_path" } + # FIXME: change default dir to /opt/stuff if app ain't a webapp ... + + def check_availability(self, context): + if os.path.exists(self.dir): + raise YunohostValidationError(f"Folder {self.dir} already exists") + def provision_or_update(self, context: Dict): if context["app_action"] in ["install", "restore"]: @@ -218,12 +290,16 @@ class InstalldirAppResource(AppResource): class DatadirAppResource(AppResource): - type = "datadir" + type = "data_dir" default_properties = { - "dir": "/home/yunohost.app/__APP__", + "dir": "/home/yunohost.app/__APP__", # FIXME or choose to move this elsewhere nowadays idk... } + def check_availability(self, context): + if os.path.exists(self.dir): + raise YunohostValidationError(f"Folder {self.dir} already exists") + def provision_or_update(self, context: Dict): if "datadir" not in context["app_settings"]: context["app_settings"]["datadir"] = self.dir @@ -240,8 +316,16 @@ class DBAppResource(AppResource): "type": "mysql" } + def check_availability(self, context): + # FIXME : checking availability sort of imply that mysql / postgresql is installed + # or we gotta make sure mariadb-server or postgresql is gonna be installed (apt resource) + pass + def provision_or_update(self, context: str): raise NotImplementedError() def deprovision(self, context: Dict): raise NotImplementedError() + + +AppResourceClassesByType = {c.type: c for c in AppResource.__subclasses__()}