This commit is contained in:
Weblate 2023-04-07 19:06:29 +02:00
commit 6bd8da807e
28 changed files with 4546 additions and 2545 deletions

View file

@ -1,78 +0,0 @@
#!/bin/bash
#=================================================
# N UPDATING HELPER
#=================================================
# This script is meant to be run by GitHub Actions.
# It is derived from the Updater script from the YunoHost-Apps organization.
# It aims to automate the update of `n`, the Node version management system.
#=================================================
# FETCHING LATEST RELEASE AND ITS ASSETS
#=================================================
# Fetching information
source helpers/nodejs
current_version="$n_version"
repo="tj/n"
# Some jq magic is needed, because the latest upstream release is not always the latest version (e.g. security patches for older versions)
version=$(curl --silent "https://api.github.com/repos/$repo/releases" | jq -r '.[] | select( .prerelease != true ) | .tag_name' | sort -V | tail -1)
# Later down the script, we assume the version has only digits and dots
# Sometimes the release name starts with a "v", so let's filter it out.
if [[ ${version:0:1} == "v" || ${version:0:1} == "V" ]]; then
version=${version:1}
fi
# Setting up the environment variables
echo "Current version: $current_version"
echo "Latest release from upstream: $version"
echo "VERSION=$version" >> $GITHUB_ENV
# For the time being, let's assume the script will fail
echo "PROCEED=false" >> $GITHUB_ENV
# Proceed only if the retrieved version is greater than the current one
if ! dpkg --compare-versions "$current_version" "lt" "$version" ; then
echo "::warning ::No new version available"
exit 0
# Proceed only if a PR for this new version does not already exist
elif git ls-remote -q --exit-code --heads https://github.com/${GITHUB_REPOSITORY:-YunoHost/yunohost}.git ci-auto-update-n-v$version ; then
echo "::warning ::A branch already exists for this update"
exit 0
fi
#=================================================
# UPDATE SOURCE FILES
#=================================================
asset_url="https://github.com/tj/n/archive/v${version}.tar.gz"
echo "Handling asset at $asset_url"
# Create the temporary directory
tempdir="$(mktemp -d)"
# Download sources and calculate checksum
filename=${asset_url##*/}
curl --silent -4 -L $asset_url -o "$tempdir/$filename"
checksum=$(sha256sum "$tempdir/$filename" | head -c 64)
# Delete temporary directory
rm -rf $tempdir
echo "Calculated checksum for n v${version} is $checksum"
#=================================================
# GENERIC FINALIZATION
#=================================================
# Replace new version in helper
sed -i -E "s/^n_version=.*$/n_version=$version/" helpers/nodejs
# Replace checksum in helper
sed -i -E "s/^n_checksum=.*$/n_checksum=$checksum/" helpers/nodejs
# The Action will proceed only if the PROCEED environment variable is set to true
echo "PROCEED=true" >> $GITHUB_ENV
exit 0

View file

@ -21,7 +21,8 @@ jobs:
git config --global user.name 'yunohost-bot'
git config --global user.email 'yunohost-bot@users.noreply.github.com'
# Run the updater script
/bin/bash .github/workflows/n_updater.sh
wget https://raw.githubusercontent.com/tj/n/master/bin/n --output-document=helpers/vendor/n/n
[[ -z "$(git diff helpers/vendor/n/n)" ]] || echo "PROCEED=true" >> $GITHUB_ENV
- name: Commit changes
id: commit
if: ${{ env.PROCEED == 'true' }}

37
debian/changelog vendored
View file

@ -1,3 +1,40 @@
yunohost (11.1.17) stable; urgency=low
- domains: fix autodns for gandi root domain ([#1634](https://github.com/yunohost/yunohost/pull/1634))
- helpers: fix previous change about using YNH_APP_ACTION ... which is not defined in config panel context (8c25aa9b)
- appsv2: for the dir/subdirs of data_dir, create parent folders if they don't exist (9a4267ff)
- quality: Split utils/config.py ([#1635](https://github.com/yunohost/yunohost/pull/1635))
- quality: Rework questions/options tests ([#1629](https://github.com/yunohost/yunohost/pull/1629))
Thanks to all contributors <3 ! (axolotle, Kayou)
-- Alexandre Aubin <alex.aubin@mailoo.org> Wed, 05 Apr 2023 16:00:09 +0200
yunohost (11.1.16) stable; urgency=low
- apps: fix i18n panel+section names ([#1630](https://github.com/yunohost/yunohost/pull/1630))
- appsv2: don't remove yhh-deps virtual package if it doesn't exist. Otherwise when apt fails to install dependency, we end up with another error about failing to remove the ynh-deps package (3656c199)
- appsv2: add validation for expected types for permissions stuff (b2596f32)
- appsv2: add support for subdirs property in data_dir (4b46f322)
- appsv2: various fixes regarding sources toml parsing/caching (14bf2ee4)
- appsv2: add documentation about the new 'autoupdate' mechanism for app sources (63981aac)
- ynh_setup_source: fix buggy checksum mismatch handling, can't compute the sha256sum after we delete the file @_@ (1b2fa91f)
- users: fix quota parsing being wrong by a factor 1000 ... doveadm returns kilos, not bytes (821aedef)
- backup: fix boring issue where archive is a broken symlink... (a95d10e5)
Thanks to all contributors <3 ! (axolotle)
-- Alexandre Aubin <alex.aubin@mailoo.org> Sun, 02 Apr 2023 20:29:33 +0200
yunohost (11.1.15) stable; urgency=low
- doc: Fix version number in autogenerated resource doc (5b58e0e6)
- helpers: Fix documentation for ynh_setup_source (7491dd4c)
- helpers: fix ynh_setup_source, 'source_id' may contain slashes x_x (eaf7a290)
- helpers/nodejs: simplify 'n' script install and maintenance ([#1627](https://github.com/yunohost/yunohost/pull/1627))
-- Alexandre Aubin <alex.aubin@mailoo.org> Sat, 11 Mar 2023 16:50:50 +0100
yunohost (11.1.14) stable; urgency=low
- helpers: simplify --time display option for ynh_script_progression .. we don't care about displaying time when below 10 sc (8731f77a)

View file

@ -2,7 +2,7 @@ import ast
import datetime
import subprocess
version = (open("../debian/changelog").readlines()[0].split()[1].strip("()"),)
version = open("../debian/changelog").readlines()[0].split()[1].strip("()")
today = datetime.datetime.now().strftime("%d/%m/%Y")

View file

@ -370,7 +370,13 @@ ynh_remove_app_dependencies() {
apt-mark unhold ${dep_app}-ynh-deps
fi
ynh_package_autopurge ${dep_app}-ynh-deps # Remove the fake package and its dependencies if they not still used.
# Remove the fake package and its dependencies if they not still used.
# (except if dpkg doesn't know anything about the package,
# which should be symptomatic of a failed install, and we don't want bash to report an error)
if dpkg-query --show ${dep_app}-ynh-deps &>/dev/null
then
ynh_package_autopurge ${dep_app}-ynh-deps
fi
}
# Install packages from an extra repository properly.

View file

@ -309,7 +309,7 @@ ynh_script_progression() {
local print_exec_time=""
if [ $time -eq 1 ] && [ "$exec_time" -gt 10 ]; then
print_exec_time=" [$(bc <<< 'scale=1; $exec_time / 60' ) minutes]"
print_exec_time=" [$(bc <<< "scale=1; $exec_time / 60" ) minutes]"
fi
ynh_print_info "[$progression_bar] > ${message}${print_exec_time}"

View file

@ -1,32 +1,10 @@
#!/bin/bash
n_version=9.0.1
n_checksum=ad305e8ee9111aa5b08e6dbde23f01109401ad2d25deecacd880b3f9ea45702b
n_install_dir="/opt/node_n"
node_version_path="$n_install_dir/n/versions/node"
# N_PREFIX is the directory of n, it needs to be loaded as a environment variable.
export N_PREFIX="$n_install_dir"
# Install Node version management
#
# [internal]
#
# usage: ynh_install_n
#
# Requires YunoHost version 2.7.12 or higher.
ynh_install_n() {
# Build an app.src for n
echo "SOURCE_URL=https://github.com/tj/n/archive/v${n_version}.tar.gz
SOURCE_SUM=${n_checksum}" >"$YNH_APP_BASEDIR/conf/n.src"
# Download and extract n
ynh_setup_source --dest_dir="$n_install_dir/git" --source_id=n
# Install n
(
cd "$n_install_dir/git"
PREFIX=$N_PREFIX make install 2>&1
)
}
# Load the version of node for an app, and set variables.
#
# usage: ynh_use_nodejs
@ -133,14 +111,10 @@ ynh_install_nodejs() {
test -x /usr/bin/node && mv /usr/bin/node /usr/bin/node_n
test -x /usr/bin/npm && mv /usr/bin/npm /usr/bin/npm_n
# If n is not previously setup, install it
if ! $n_install_dir/bin/n --version >/dev/null 2>&1; then
ynh_install_n
elif dpkg --compare-versions "$($n_install_dir/bin/n --version)" lt $n_version; then
ynh_install_n
fi
# Modify the default N_PREFIX in n script
# Install (or update if YunoHost vendor/ folder updated since last install) n
mkdir -p $n_install_dir/bin/
cp /usr/share/yunohost/helpers.d/vendor/n/n $n_install_dir/bin/n
# Tweak for n to understand it's installed in $N_PREFIX
ynh_replace_string --match_string="^N_PREFIX=\${N_PREFIX-.*}$" --replace_string="N_PREFIX=\${N_PREFIX-$N_PREFIX}" --target_file="$n_install_dir/bin/n"
# Restore /usr/local/bin in PATH

View file

@ -22,7 +22,10 @@ YNH_APP_BASEDIR=${YNH_APP_BASEDIR:-$(realpath ..)}
ynh_exit_properly() {
local exit_code=$?
rm -rf "/var/cache/yunohost/download/"
if [[ "${YNH_APP_ACTION:-}" =~ ^install$|^upgrade$|^restore$ ]]
then
rm -rf "/var/cache/yunohost/download/"
fi
if [ "$exit_code" -eq 0 ]; then
exit 0 # Exit without error if the script ended correctly
@ -89,7 +92,9 @@ fi
# sha256 = "0123456789abcdef" # The sha256 sum of the asset obtained from the URL
# ```
#
# # Optional flags:
# ##### Optional flags
#
# ```text
# format = "tar.gz"/xz/bz2 # automatically guessed from the extension of the URL, but can be set explicitly. Will use `tar` to extract
# "zip" # automatically guessed from the extension of the URL, but can be set explicitly. Will use `unzip` to extract
# "docker" # useful to extract files from an already-built docker image (instead of rebuilding them locally). Will use `docker-image-extract` to extract
@ -105,7 +110,7 @@ fi
#
# rename = "whatever_your_want" # to be used for convenience when `extract` is false and the default name of the file is not practical
# platform = "linux/amd64" # (defaults to "linux/$YNH_ARCH") to be used in conjonction with `format = "docker"` to specify which architecture to extract for
#
# ```
#
# You may also define assets url and checksum per-architectures such as:
# ```toml
@ -162,22 +167,22 @@ ynh_setup_source() {
if test -e $YNH_APP_BASEDIR/manifest.toml && cat $YNH_APP_BASEDIR/manifest.toml | toml_to_json | jq -e '.resources.sources' >/dev/null
then
source_id="${source_id:-main}"
local sources_json=$(cat $YNH_APP_BASEDIR/manifest.toml | toml_to_json | jq '.resources.sources')
if ! echo "$sources_json" | jq -re ".$source_id.url"
local sources_json=$(cat $YNH_APP_BASEDIR/manifest.toml | toml_to_json | jq ".resources.sources[\"$source_id\"]")
if jq -re ".url" <<< "$sources_json"
then
local arch_prefix=".$YNH_ARCH"
else
local arch_prefix=""
else
local arch_prefix=".$YNH_ARCH"
fi
local src_url="$(echo "$sources_json" | jq -r ".$source_id$arch_prefix.url" | sed 's/^null$//')"
local src_sum="$(echo "$sources_json" | jq -r ".$source_id$arch_prefix.sha256" | sed 's/^null$//')"
local src_url="$(jq -r "$arch_prefix.url" <<< "$sources_json" | sed 's/^null$//')"
local src_sum="$(jq -r "$arch_prefix.sha256" <<< "$sources_json" | sed 's/^null$//')"
local src_sumprg="sha256sum"
local src_format="$(echo "$sources_json" | jq -r ".$source_id.format" | sed 's/^null$//')"
local src_in_subdir="$(echo "$sources_json" | jq -r ".$source_id.in_subdir" | sed 's/^null$//')"
local src_extract="$(echo "$sources_json" | jq -r ".$source_id.extract" | sed 's/^null$//')"
local src_platform="$(echo "$sources_json" | jq -r ".$source_id.platform" | sed 's/^null$//')"
local src_rename="$(echo "$sources_json" | jq -r ".$source_id.rename" | sed 's/^null$//')"
local src_format="$(jq -r ".format" <<< "$sources_json" | sed 's/^null$//')"
local src_in_subdir="$(jq -r ".in_subdir" <<< "$sources_json" | sed 's/^null$//')"
local src_extract="$(jq -r ".extract" <<< "$sources_json" | sed 's/^null$//')"
local src_platform="$(jq -r ".platform" <<< "$sources_json" | sed 's/^null$//')"
local src_rename="$(jq -r ".rename" <<< "$sources_json" | sed 's/^null$//')"
[[ -n "$src_url" ]] || ynh_die "No URL defined for source $source_id$arch_prefix ?"
[[ -n "$src_sum" ]] || ynh_die "No sha256 sum defined for source $source_id$arch_prefix ?"
@ -233,8 +238,9 @@ ynh_setup_source() {
# (Unused?) mecanism where one can have the file in a special local cache to not have to download it...
local local_src="/opt/yunohost-apps-src/${YNH_APP_ID}/${source_id}"
mkdir -p /var/cache/yunohost/download/${YNH_APP_ID}/
src_filename="/var/cache/yunohost/download/${YNH_APP_ID}/${source_id}"
# Gotta use this trick with 'dirname' because source_id may contain slashes x_x
mkdir -p $(dirname /var/cache/yunohost/download/${YNH_APP_INSTANCE_NAME}/${source_id})
src_filename="/var/cache/yunohost/download/${YNH_APP_INSTANCE_NAME}/${source_id}"
if [ "$src_format" = "docker" ]; then
src_platform="${src_platform:-"linux/$YNH_ARCH"}"
@ -264,8 +270,10 @@ ynh_setup_source() {
# Check the control sum
if ! echo "${src_sum} ${src_filename}" | ${src_sumprg} --check --status
then
rm ${src_filename}
ynh_die --message="Corrupt source for ${src_filename}: Expected ${src_sum} but got $(${src_sumprg} ${src_filename} | cut --delimiter=' ' --fields=1) (size: $(du -hs ${src_filename} | cut --delimiter=' ' --fields=1))."
local actual_sum="$(${src_sumprg} ${src_filename} | cut --delimiter=' ' --fields=1)"
local actual_size="$(du -hs ${src_filename} | cut --delimiter=' ' --fields=1)"
rm -f ${src_filename}
ynh_die --message="Corrupt source for ${src_filename}: Expected ${src_sum} but got ${actual_sum} (size: ${actual_size})."
fi
fi

21
helpers/vendor/n/LICENSE vendored Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2018 TJ Holowaychuk
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

1
helpers/vendor/n/README.md vendored Normal file
View file

@ -0,0 +1 @@
This is taken from https://github.com/tj/n/

1621
helpers/vendor/n/n vendored Executable file

File diff suppressed because it is too large Load diff

View file

@ -257,4 +257,4 @@
"diagnosis_ip_dnsresolution_working": "تحليل اسم النطاق يعمل!",
"diagnosis_mail_blacklist_listed_by": "إن عنوان الـ IP الخاص بك أو نطاقك <code>{item}</code> مُدرَج ضمن قائمة سوداء على {blacklist_name}",
"diagnosis_mail_outgoing_port_25_ok": "خادم بريد SMTP قادر على إرسال رسائل البريد الإلكتروني (منفذ البريد الصادر 25 غير محظور)."
}
}

View file

@ -705,4 +705,4 @@
"app_change_url_failed": "Kann die URL für {app} nicht ändern: {error}",
"app_change_url_script_failed": "Es ist ein Fehler im URL-Änderungs-Script aufgetreten",
"app_resource_failed": "Automatische Ressourcen-Allokation (provisioning), die Unterbindung des Zugriffts auf Ressourcen (deprovisioning) oder die Aktualisierung der Ressourcen für {app} schlug fehl: {error}"
}
}

View file

@ -26,9 +26,9 @@
"app_change_url_success": "{app} URL is now {domain}{path}",
"app_config_unable_to_apply": "Failed to apply config panel values.",
"app_config_unable_to_read": "Failed to read config panel values.",
"app_failed_to_download_asset": "Failed to download asset '{source_id}' ({url}) for {app}: {out}",
"app_corrupt_source": "YunoHost was able to download the asset '{source_id}' ({url}) for {app}, but the asset doesn't match the expected checksum. This could mean that some temporary network failure happened on your server, OR the asset was somehow changed by the upstream maintainer (or a malicious actor?) and YunoHost packagers need to investigate and update the app manifest to reflect this change.\n Expected sha256 checksum: {expected_sha256}\n Downloaded sha256 checksum: {computed_sha256}\n Downloaded file size: {size}",
"app_extraction_failed": "Could not extract the installation files",
"app_failed_to_download_asset": "Failed to download asset '{source_id}' ({url}) for {app}: {out}",
"app_failed_to_upgrade_but_continue": "App {failed_app} failed to upgrade, continue to next upgrades as requested. Run 'yunohost log show {operation_logger_name}' to see failure log",
"app_full_domain_unavailable": "Sorry, this app must be installed on a domain of its own, but other apps are already installed on the domain '{domain}'. You could use a subdomain dedicated to this app instead.",
"app_id_invalid": "Invalid app ID",

View file

@ -761,4 +761,4 @@
"invalid_shell": "Intérprete de ordes non válido: {shell}",
"log_resource_snippet": "Aprovisionamento/desaprovisionamento/actualización dun recurso",
"app_resource_failed": "Fallou o aprovisionamento, desaprovisionamento ou actualización de recursos para {app}: {error}"
}
}

View file

@ -469,4 +469,4 @@
"global_settings_setting_user_strength": "Fòrça del senhal utilizaire",
"global_settings_setting_postfix_compatibility_help": "Solucion de compromés entre compatibilitat e seguretat pel servidor Postfix. Afècta los criptografs (e dautres aspèctes ligats amb la seguretat)",
"global_settings_setting_ssh_compatibility_help": "Solucion de compromés entre compatibilitat e seguretat pel servidor SSH. Afècta los criptografs (e dautres aspèctes ligats amb la seguretat)"
}
}

View file

@ -180,4 +180,4 @@
"certmanager_cert_renew_failed": "Nieudane odnowienie certyfikatu Let's Encrypt dla {domains}",
"apps_failed_to_upgrade": "Nieudana aktualizacja aplikacji: {apps}",
"backup_output_directory_required": "Musisz wybrać katalog dla kopii zapasowej"
}
}

View file

@ -736,7 +736,7 @@
"password_confirmation_not_the_same": "Пароль і його підтвердження не збігаються",
"password_too_long": "Будь ласка, виберіть пароль коротший за 127 символів",
"pattern_fullname": "Має бути дійсне повне ім’я (принаймні 3 символи)",
"app_failed_to_upgrade_but_continue": "Застосунок {failed_app} не вдалося оновити, продовжуйте наступні оновлення відповідно до запиту. Запустіть 'yunohost log show {назваоггера_операції}', щоб побачити журнал помилок",
"app_failed_to_upgrade_but_continue": "Застосунок {failed_app} не вдалося оновити, продовжуйте наступні оновлення відповідно до запиту. Запустіть 'yunohost log show {operation_logger_name}', щоб побачити журнал помилок",
"app_not_upgraded_broken_system": "Застосунок '{failed_app}' не зміг оновитися і перевів систему в неробочий стан, і як наслідок, оновлення наступних застосунків було скасовано: {apps}",
"app_not_upgraded_broken_system_continue": "Застосунок '{failed_app}' не зміг оновитися і перевів систему у неробочий стан (тому --continue-on-failure ігнорується), і як наслідок, оновлення наступних застосунків було скасовано: {apps}",
"confirm_app_insufficient_ram": "НЕБЕЗПЕКА! Цей застосунок вимагає {required} оперативної пам'яті для встановлення/оновлення, але зараз доступно лише {current}. Навіть якби цей застосунок можна було б запустити, процес його встановлення/оновлення вимагає великої кількості оперативної пам'яті, тому ваш сервер може зависнути і вийти з ладу. Якщо ви все одно готові піти на цей ризик, введіть '{answers}'",
@ -760,5 +760,5 @@
"app_not_enough_ram": "Для встановлення/оновлення цього застосунку потрібно {required} оперативної пам'яті, але наразі доступно лише {current}.",
"app_resource_failed": "Не вдалося надати, позбавити або оновити ресурси для {app}: {error}",
"apps_failed_to_upgrade": "Ці застосунки не вдалося оновити:{apps}",
"apps_failed_to_upgrade_line": "\n * {app_id} (щоб побачити відповідний журнал, виконайте 'yunohost log show {назваоггера_операції}')"
}
"apps_failed_to_upgrade_line": "\n * {app_id} (щоб побачити відповідний журнал, виконайте 'yunohost log show {operation_logger_name}')"
}

View file

@ -48,9 +48,8 @@ from moulinette.utils.filesystem import (
chmod,
)
from yunohost.utils.config import (
ConfigPanel,
ask_questions_and_parse_answers,
from yunohost.utils.configpanel import ConfigPanel, ask_questions_and_parse_answers
from yunohost.utils.form import (
DomainQuestion,
PathQuestion,
hydrate_questions_with_choices,
@ -1444,7 +1443,9 @@ def app_remove(operation_logger, app, purge=False, force_workdir=None):
from yunohost.utils.resources import AppResourceManager
AppResourceManager(app, wanted={}, current=manifest).apply(
rollback_and_raise_exception_if_failure=False, purge_data_dir=purge, action="remove"
rollback_and_raise_exception_if_failure=False,
purge_data_dir=purge,
action="remove",
)
else:
# Remove all permission in LDAP

View file

@ -2376,6 +2376,7 @@ def backup_list(with_info=False, human_readable=False):
# (we do a realpath() to resolve symlinks)
archives = glob(f"{ARCHIVES_PATH}/*.tar.gz") + glob(f"{ARCHIVES_PATH}/*.tar")
archives = {os.path.realpath(archive) for archive in archives}
archives = {archive for archive in archives if os.path.exists(archive)}
archives = sorted(archives, key=lambda x: os.path.getctime(x))
# Extract only filename without the extension

View file

@ -960,6 +960,9 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
f"Pushing {record['type']} records is not properly supported by Lexicon/Godaddy."
)
continue
elif registrar == "gandi":
if record["name"] == base_dns_zone:
record["name"] = "@." + record["name"]
record["action"] = action
query = (

View file

@ -33,7 +33,8 @@ from yunohost.app import (
_get_conflicting_apps,
)
from yunohost.regenconf import regen_conf, _force_clear_hashes, _process_regen_conf
from yunohost.utils.config import ConfigPanel, Question
from yunohost.utils.configpanel import ConfigPanel
from yunohost.utils.form import Question
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.log import is_unit_operation

View file

@ -21,7 +21,8 @@ import subprocess
from moulinette import m18n
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.utils.config import ConfigPanel, Question
from yunohost.utils.configpanel import ConfigPanel
from yunohost.utils.form import Question
from moulinette.utils.log import getActionLogger
from yunohost.regenconf import regen_conf
from yunohost.firewall import firewall_reload

File diff suppressed because it is too large Load diff

View file

@ -631,7 +631,7 @@ def user_info(username):
has_value = re.search(r"Value=(\d+)", cmd_result)
if has_value:
storage_use = int(has_value.group(1))
storage_use = int(has_value.group(1)) * 1000
storage_use = binary_to_human(storage_use)
if is_limited:

694
src/utils/configpanel.py Normal file
View file

@ -0,0 +1,694 @@
#
# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as
# published by the Free Software Foundation, either version 3 of the
# License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import glob
import os
import re
import urllib.parse
from collections import OrderedDict
from typing import Union
from moulinette.interfaces.cli import colorize
from moulinette import Moulinette, m18n
from moulinette.utils.log import getActionLogger
from moulinette.utils.filesystem import (
read_toml,
read_yaml,
write_to_yaml,
mkdir,
)
from yunohost.utils.i18n import _value_for_locale
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.utils.form import (
ARGUMENTS_TYPE_PARSERS,
FileQuestion,
Question,
ask_questions_and_parse_answers,
evaluate_simple_js_expression,
)
logger = getActionLogger("yunohost.configpanel")
CONFIG_PANEL_VERSION_SUPPORTED = 1.0
class ConfigPanel:
entity_type = "config"
save_path_tpl: Union[str, None] = None
config_path_tpl = "/usr/share/yunohost/config_{entity_type}.toml"
save_mode = "full"
@classmethod
def list(cls):
"""
List available config panel
"""
try:
entities = [
re.match(
"^" + cls.save_path_tpl.format(entity="(?p<entity>)") + "$", f
).group("entity")
for f in glob.glob(cls.save_path_tpl.format(entity="*"))
if os.path.isfile(f)
]
except FileNotFoundError:
entities = []
return entities
def __init__(self, entity, config_path=None, save_path=None, creation=False):
self.entity = entity
self.config_path = config_path
if not config_path:
self.config_path = self.config_path_tpl.format(
entity=entity, entity_type=self.entity_type
)
self.save_path = save_path
if not save_path and self.save_path_tpl:
self.save_path = self.save_path_tpl.format(entity=entity)
self.config = {}
self.values = {}
self.new_values = {}
if (
self.save_path
and self.save_mode != "diff"
and not creation
and not os.path.exists(self.save_path)
):
raise YunohostValidationError(
f"{self.entity_type}_unknown", **{self.entity_type: entity}
)
if self.save_path and creation and os.path.exists(self.save_path):
raise YunohostValidationError(
f"{self.entity_type}_exists", **{self.entity_type: entity}
)
# Search for hooks in the config panel
self.hooks = {
func: getattr(self, func)
for func in dir(self)
if callable(getattr(self, func))
and re.match("^(validate|post_ask)__", func)
}
def get(self, key="", mode="classic"):
self.filter_key = key or ""
# Read config panel toml
self._get_config_panel()
if not self.config:
raise YunohostValidationError("config_no_panel")
# Read or get values and hydrate the config
self._load_current_values()
self._hydrate()
# In 'classic' mode, we display the current value if key refer to an option
if self.filter_key.count(".") == 2 and mode == "classic":
option = self.filter_key.split(".")[-1]
value = self.values.get(option, None)
option_type = None
for _, _, option_ in self._iterate():
if option_["id"] == option:
option_type = ARGUMENTS_TYPE_PARSERS[option_["type"]]
break
return option_type.normalize(value) if option_type else value
# Format result in 'classic' or 'export' mode
logger.debug(f"Formating result in '{mode}' mode")
result = {}
for panel, section, option in self._iterate():
if section["is_action_section"] and mode != "full":
continue
key = f"{panel['id']}.{section['id']}.{option['id']}"
if mode == "export":
result[option["id"]] = option.get("current_value")
continue
ask = None
if "ask" in option:
ask = _value_for_locale(option["ask"])
elif "i18n" in self.config:
ask = m18n.n(self.config["i18n"] + "_" + option["id"])
if mode == "full":
option["ask"] = ask
question_class = ARGUMENTS_TYPE_PARSERS[option.get("type", "string")]
# FIXME : maybe other properties should be taken from the question, not just choices ?.
option["choices"] = question_class(option).choices
option["default"] = question_class(option).default
option["pattern"] = question_class(option).pattern
else:
result[key] = {"ask": ask}
if "current_value" in option:
question_class = ARGUMENTS_TYPE_PARSERS[
option.get("type", "string")
]
result[key]["value"] = question_class.humanize(
option["current_value"], option
)
# FIXME: semantics, technically here this is not about a prompt...
if question_class.hide_user_input_in_prompt:
result[key][
"value"
] = "**************" # Prevent displaying password in `config get`
if mode == "full":
return self.config
else:
return result
def list_actions(self):
actions = {}
# FIXME : meh, loading the entire config panel is again going to cause
# stupid issues for domain (e.g loading registrar stuff when willing to just list available actions ...)
self.filter_key = ""
self._get_config_panel()
for panel, section, option in self._iterate():
if option["type"] == "button":
key = f"{panel['id']}.{section['id']}.{option['id']}"
actions[key] = _value_for_locale(option["ask"])
return actions
def run_action(self, action=None, args=None, args_file=None, operation_logger=None):
#
# FIXME : this stuff looks a lot like set() ...
#
self.filter_key = ".".join(action.split(".")[:2])
action_id = action.split(".")[2]
# Read config panel toml
self._get_config_panel()
# FIXME: should also check that there's indeed a key called action
if not self.config:
raise YunohostValidationError(f"No action named {action}", raw_msg=True)
# Import and parse pre-answered options
logger.debug("Import and parse pre-answered options")
self._parse_pre_answered(args, None, args_file)
# Read or get values and hydrate the config
self._load_current_values()
self._hydrate()
Question.operation_logger = operation_logger
self._ask(action=action_id)
# FIXME: here, we could want to check constrains on
# the action's visibility / requirements wrt to the answer to questions ...
if operation_logger:
operation_logger.start()
try:
self._run_action(action_id)
except YunohostError:
raise
# Script got manually interrupted ...
# N.B. : KeyboardInterrupt does not inherit from Exception
except (KeyboardInterrupt, EOFError):
error = m18n.n("operation_interrupted")
logger.error(m18n.n("config_action_failed", action=action, error=error))
raise
# Something wrong happened in Yunohost's code (most probably hook_exec)
except Exception:
import traceback
error = m18n.n("unexpected_error", error="\n" + traceback.format_exc())
logger.error(m18n.n("config_action_failed", action=action, error=error))
raise
finally:
# Delete files uploaded from API
# FIXME : this is currently done in the context of config panels,
# but could also happen in the context of app install ... (or anywhere else
# where we may parse args etc...)
FileQuestion.clean_upload_dirs()
# FIXME: i18n
logger.success(f"Action {action_id} successful")
operation_logger.success()
def set(
self, key=None, value=None, args=None, args_file=None, operation_logger=None
):
self.filter_key = key or ""
# Read config panel toml
self._get_config_panel()
if not self.config:
raise YunohostValidationError("config_no_panel")
if (args is not None or args_file is not None) and value is not None:
raise YunohostValidationError(
"You should either provide a value, or a serie of args/args_file, but not both at the same time",
raw_msg=True,
)
if self.filter_key.count(".") != 2 and value is not None:
raise YunohostValidationError("config_cant_set_value_on_section")
# Import and parse pre-answered options
logger.debug("Import and parse pre-answered options")
self._parse_pre_answered(args, value, args_file)
# Read or get values and hydrate the config
self._load_current_values()
self._hydrate()
Question.operation_logger = operation_logger
self._ask()
if operation_logger:
operation_logger.start()
try:
self._apply()
except YunohostError:
raise
# Script got manually interrupted ...
# N.B. : KeyboardInterrupt does not inherit from Exception
except (KeyboardInterrupt, EOFError):
error = m18n.n("operation_interrupted")
logger.error(m18n.n("config_apply_failed", error=error))
raise
# Something wrong happened in Yunohost's code (most probably hook_exec)
except Exception:
import traceback
error = m18n.n("unexpected_error", error="\n" + traceback.format_exc())
logger.error(m18n.n("config_apply_failed", error=error))
raise
finally:
# Delete files uploaded from API
# FIXME : this is currently done in the context of config panels,
# but could also happen in the context of app install ... (or anywhere else
# where we may parse args etc...)
FileQuestion.clean_upload_dirs()
self._reload_services()
logger.success("Config updated as expected")
operation_logger.success()
def _get_toml(self):
return read_toml(self.config_path)
def _get_config_panel(self):
# Split filter_key
filter_key = self.filter_key.split(".") if self.filter_key != "" else []
if len(filter_key) > 3:
raise YunohostError(
f"The filter key {filter_key} has too many sub-levels, the max is 3.",
raw_msg=True,
)
if not os.path.exists(self.config_path):
logger.debug(f"Config panel {self.config_path} doesn't exists")
return None
toml_config_panel = self._get_toml()
# Check TOML config panel is in a supported version
if float(toml_config_panel["version"]) < CONFIG_PANEL_VERSION_SUPPORTED:
logger.error(
f"Config panels version {toml_config_panel['version']} are not supported"
)
return None
# Transform toml format into internal format
format_description = {
"root": {
"properties": ["version", "i18n"],
"defaults": {"version": 1.0},
},
"panels": {
"properties": ["name", "services", "actions", "help"],
"defaults": {
"services": [],
"actions": {"apply": {"en": "Apply"}},
},
},
"sections": {
"properties": ["name", "services", "optional", "help", "visible"],
"defaults": {
"name": "",
"services": [],
"optional": True,
"is_action_section": False,
},
},
"options": {
"properties": [
"ask",
"type",
"bind",
"help",
"example",
"default",
"style",
"icon",
"placeholder",
"visible",
"optional",
"choices",
"yes",
"no",
"pattern",
"limit",
"min",
"max",
"step",
"accept",
"redact",
"filter",
"readonly",
"enabled",
# "confirm", # TODO: to ask confirmation before running an action
],
"defaults": {},
},
}
def _build_internal_config_panel(raw_infos, level):
"""Convert TOML in internal format ('full' mode used by webadmin)
Here are some properties of 1.0 config panel in toml:
- node properties and node children are mixed,
- text are in english only
- some properties have default values
This function detects all children nodes and put them in a list
"""
defaults = format_description[level]["defaults"]
properties = format_description[level]["properties"]
# Start building the ouput (merging the raw infos + defaults)
out = {key: raw_infos.get(key, value) for key, value in defaults.items()}
# Now fill the sublevels (+ apply filter_key)
i = list(format_description).index(level)
sublevel = list(format_description)[i + 1] if level != "options" else None
search_key = filter_key[i] if len(filter_key) > i else False
for key, value in raw_infos.items():
# Key/value are a child node
if (
isinstance(value, OrderedDict)
and key not in properties
and sublevel
):
# We exclude all nodes not referenced by the filter_key
if search_key and key != search_key:
continue
subnode = _build_internal_config_panel(value, sublevel)
subnode["id"] = key
if level == "root":
subnode.setdefault("name", {"en": key.capitalize()})
elif level == "sections":
subnode["name"] = key # legacy
subnode.setdefault("optional", raw_infos.get("optional", True))
# If this section contains at least one button, it becomes an "action" section
if subnode.get("type") == "button":
out["is_action_section"] = True
out.setdefault(sublevel, []).append(subnode)
# Key/value are a property
else:
if key not in properties:
logger.warning(f"Unknown key '{key}' found in config panel")
# Todo search all i18n keys
out[key] = (
value
if key not in ["ask", "help", "name"] or isinstance(value, dict)
else {"en": value}
)
return out
self.config = _build_internal_config_panel(toml_config_panel, "root")
try:
self.config["panels"][0]["sections"][0]["options"][0]
except (KeyError, IndexError):
raise YunohostValidationError(
"config_unknown_filter_key", filter_key=self.filter_key
)
# List forbidden keywords from helpers and sections toml (to avoid conflict)
forbidden_keywords = [
"old",
"app",
"changed",
"file_hash",
"binds",
"types",
"formats",
"getter",
"setter",
"short_setting",
"type",
"bind",
"nothing_changed",
"changes_validated",
"result",
"max_progression",
]
forbidden_keywords += format_description["sections"]
forbidden_readonly_types = ["password", "app", "domain", "user", "file"]
for _, _, option in self._iterate():
if option["id"] in forbidden_keywords:
raise YunohostError("config_forbidden_keyword", keyword=option["id"])
if (
option.get("readonly", False)
and option.get("type", "string") in forbidden_readonly_types
):
raise YunohostError(
"config_forbidden_readonly_type",
type=option["type"],
id=option["id"],
)
return self.config
def _hydrate(self):
# Hydrating config panel with current value
for _, section, option in self._iterate():
if option["id"] not in self.values:
allowed_empty_types = [
"alert",
"display_text",
"markdown",
"file",
"button",
]
if section["is_action_section"] and option.get("default") is not None:
self.values[option["id"]] = option["default"]
elif (
option["type"] in allowed_empty_types
or option.get("bind") == "null"
):
continue
else:
raise YunohostError(
f"Config panel question '{option['id']}' should be initialized with a value during install or upgrade.",
raw_msg=True,
)
value = self.values[option["name"]]
# Allow to use value instead of current_value in app config script.
# e.g. apps may write `echo 'value: "foobar"'` in the config file (which is more intuitive that `echo 'current_value: "foobar"'`
# For example hotspot used it...
# See https://github.com/YunoHost/yunohost/pull/1546
if (
isinstance(value, dict)
and "value" in value
and "current_value" not in value
):
value["current_value"] = value["value"]
# In general, the value is just a simple value.
# Sometimes it could be a dict used to overwrite the option itself
value = value if isinstance(value, dict) else {"current_value": value}
option.update(value)
self.values[option["id"]] = value.get("current_value")
return self.values
def _ask(self, action=None):
logger.debug("Ask unanswered question and prevalidate data")
if "i18n" in self.config:
for panel, section, option in self._iterate():
if "ask" not in option:
option["ask"] = m18n.n(self.config["i18n"] + "_" + option["id"])
# auto add i18n help text if present in locales
if m18n.key_exists(self.config["i18n"] + "_" + option["id"] + "_help"):
option["help"] = m18n.n(
self.config["i18n"] + "_" + option["id"] + "_help"
)
def display_header(message):
"""CLI panel/section header display"""
if Moulinette.interface.type == "cli" and self.filter_key.count(".") < 2:
Moulinette.display(colorize(message, "purple"))
for panel, section, obj in self._iterate(["panel", "section"]):
if (
section
and section.get("visible")
and not evaluate_simple_js_expression(
section["visible"], context=self.future_values
)
):
continue
# Ugly hack to skip action section ... except when when explicitly running actions
if not action:
if section and section["is_action_section"]:
continue
if panel == obj:
name = _value_for_locale(panel["name"])
display_header(f"\n{'='*40}\n>>>> {name}\n{'='*40}")
else:
name = _value_for_locale(section["name"])
if name:
display_header(f"\n# {name}")
elif section:
# filter action section options in case of multiple buttons
section["options"] = [
option
for option in section["options"]
if option.get("type", "string") != "button"
or option["id"] == action
]
if panel == obj:
continue
# Check and ask unanswered questions
prefilled_answers = self.args.copy()
prefilled_answers.update(self.new_values)
questions = ask_questions_and_parse_answers(
{question["name"]: question for question in section["options"]},
prefilled_answers=prefilled_answers,
current_values=self.values,
hooks=self.hooks,
)
self.new_values.update(
{
question.name: question.value
for question in questions
if question.value is not None
}
)
def _get_default_values(self):
return {
option["id"]: option["default"]
for _, _, option in self._iterate()
if "default" in option
}
@property
def future_values(self):
return {**self.values, **self.new_values}
def __getattr__(self, name):
if "new_values" in self.__dict__ and name in self.new_values:
return self.new_values[name]
if "values" in self.__dict__ and name in self.values:
return self.values[name]
return self.__dict__[name]
def _load_current_values(self):
"""
Retrieve entries in YAML file
And set default values if needed
"""
# Inject defaults if needed (using the magic .update() ;))
self.values = self._get_default_values()
# Retrieve entries in the YAML
if os.path.exists(self.save_path) and os.path.isfile(self.save_path):
self.values.update(read_yaml(self.save_path) or {})
def _parse_pre_answered(self, args, value, args_file):
args = urllib.parse.parse_qs(args or "", keep_blank_values=True)
self.args = {key: ",".join(value_) for key, value_ in args.items()}
if args_file:
# Import YAML / JSON file but keep --args values
self.args = {**read_yaml(args_file), **self.args}
if value is not None:
self.args = {self.filter_key.split(".")[-1]: value}
def _apply(self):
logger.info("Saving the new configuration...")
dir_path = os.path.dirname(os.path.realpath(self.save_path))
if not os.path.exists(dir_path):
mkdir(dir_path, mode=0o700)
values_to_save = self.future_values
if self.save_mode == "diff":
defaults = self._get_default_values()
values_to_save = {
k: v for k, v in values_to_save.items() if defaults.get(k) != v
}
# Save the settings to the .yaml file
write_to_yaml(self.save_path, values_to_save)
def _reload_services(self):
from yunohost.service import service_reload_or_restart
services_to_reload = set()
for panel, section, obj in self._iterate(["panel", "section", "option"]):
services_to_reload |= set(obj.get("services", []))
services_to_reload = list(services_to_reload)
services_to_reload.sort(key="nginx".__eq__)
if services_to_reload:
logger.info("Reloading services...")
for service in services_to_reload:
if hasattr(self, "entity"):
service = service.replace("__APP__", self.entity)
service_reload_or_restart(service)
def _iterate(self, trigger=["option"]):
for panel in self.config.get("panels", []):
if "panel" in trigger:
yield (panel, None, panel)
for section in panel.get("sections", []):
if "section" in trigger:
yield (panel, section, section)
if "option" in trigger:
for option in section.get("options", []):
yield (panel, section, option)

View file

@ -16,7 +16,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
import glob
import os
import re
import urllib.parse
@ -24,7 +23,6 @@ import tempfile
import shutil
import ast
import operator as op
from collections import OrderedDict
from typing import Optional, Dict, List, Union, Any, Mapping, Callable
from moulinette.interfaces.cli import colorize
@ -33,18 +31,13 @@ from moulinette.utils.log import getActionLogger
from moulinette.utils.filesystem import (
read_file,
write_to_file,
read_toml,
read_yaml,
write_to_yaml,
mkdir,
)
from yunohost.utils.i18n import _value_for_locale
from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.log import OperationLogger
logger = getActionLogger("yunohost.config")
CONFIG_PANEL_VERSION_SUPPORTED = 1.0
logger = getActionLogger("yunohost.form")
# Those js-like evaluate functions are used to eval safely visible attributes
@ -190,651 +183,6 @@ def evaluate_simple_js_expression(expr, context={}):
return evaluate_simple_ast(node, context)
class ConfigPanel:
entity_type = "config"
save_path_tpl: Union[str, None] = None
config_path_tpl = "/usr/share/yunohost/config_{entity_type}.toml"
save_mode = "full"
@classmethod
def list(cls):
"""
List available config panel
"""
try:
entities = [
re.match(
"^" + cls.save_path_tpl.format(entity="(?p<entity>)") + "$", f
).group("entity")
for f in glob.glob(cls.save_path_tpl.format(entity="*"))
if os.path.isfile(f)
]
except FileNotFoundError:
entities = []
return entities
def __init__(self, entity, config_path=None, save_path=None, creation=False):
self.entity = entity
self.config_path = config_path
if not config_path:
self.config_path = self.config_path_tpl.format(
entity=entity, entity_type=self.entity_type
)
self.save_path = save_path
if not save_path and self.save_path_tpl:
self.save_path = self.save_path_tpl.format(entity=entity)
self.config = {}
self.values = {}
self.new_values = {}
if (
self.save_path
and self.save_mode != "diff"
and not creation
and not os.path.exists(self.save_path)
):
raise YunohostValidationError(
f"{self.entity_type}_unknown", **{self.entity_type: entity}
)
if self.save_path and creation and os.path.exists(self.save_path):
raise YunohostValidationError(
f"{self.entity_type}_exists", **{self.entity_type: entity}
)
# Search for hooks in the config panel
self.hooks = {
func: getattr(self, func)
for func in dir(self)
if callable(getattr(self, func))
and re.match("^(validate|post_ask)__", func)
}
def get(self, key="", mode="classic"):
self.filter_key = key or ""
# Read config panel toml
self._get_config_panel()
if not self.config:
raise YunohostValidationError("config_no_panel")
# Read or get values and hydrate the config
self._load_current_values()
self._hydrate()
# In 'classic' mode, we display the current value if key refer to an option
if self.filter_key.count(".") == 2 and mode == "classic":
option = self.filter_key.split(".")[-1]
value = self.values.get(option, None)
option_type = None
for _, _, option_ in self._iterate():
if option_["id"] == option:
option_type = ARGUMENTS_TYPE_PARSERS[option_["type"]]
break
return option_type.normalize(value) if option_type else value
# Format result in 'classic' or 'export' mode
logger.debug(f"Formating result in '{mode}' mode")
result = {}
for panel, section, option in self._iterate():
if section["is_action_section"] and mode != "full":
continue
key = f"{panel['id']}.{section['id']}.{option['id']}"
if mode == "export":
result[option["id"]] = option.get("current_value")
continue
ask = None
if "ask" in option:
ask = _value_for_locale(option["ask"])
elif "i18n" in self.config:
ask = m18n.n(self.config["i18n"] + "_" + option["id"])
if mode == "full":
option["ask"] = ask
question_class = ARGUMENTS_TYPE_PARSERS[option.get("type", "string")]
# FIXME : maybe other properties should be taken from the question, not just choices ?.
option["choices"] = question_class(option).choices
option["default"] = question_class(option).default
option["pattern"] = question_class(option).pattern
else:
result[key] = {"ask": ask}
if "current_value" in option:
question_class = ARGUMENTS_TYPE_PARSERS[
option.get("type", "string")
]
result[key]["value"] = question_class.humanize(
option["current_value"], option
)
# FIXME: semantics, technically here this is not about a prompt...
if question_class.hide_user_input_in_prompt:
result[key][
"value"
] = "**************" # Prevent displaying password in `config get`
if mode == "full":
return self.config
else:
return result
def list_actions(self):
actions = {}
# FIXME : meh, loading the entire config panel is again going to cause
# stupid issues for domain (e.g loading registrar stuff when willing to just list available actions ...)
self.filter_key = ""
self._get_config_panel()
for panel, section, option in self._iterate():
if option["type"] == "button":
key = f"{panel['id']}.{section['id']}.{option['id']}"
actions[key] = _value_for_locale(option["ask"])
return actions
def run_action(self, action=None, args=None, args_file=None, operation_logger=None):
#
# FIXME : this stuff looks a lot like set() ...
#
self.filter_key = ".".join(action.split(".")[:2])
action_id = action.split(".")[2]
# Read config panel toml
self._get_config_panel()
# FIXME: should also check that there's indeed a key called action
if not self.config:
raise YunohostValidationError(f"No action named {action}", raw_msg=True)
# Import and parse pre-answered options
logger.debug("Import and parse pre-answered options")
self._parse_pre_answered(args, None, args_file)
# Read or get values and hydrate the config
self._load_current_values()
self._hydrate()
Question.operation_logger = operation_logger
self._ask(action=action_id)
# FIXME: here, we could want to check constrains on
# the action's visibility / requirements wrt to the answer to questions ...
if operation_logger:
operation_logger.start()
try:
self._run_action(action_id)
except YunohostError:
raise
# Script got manually interrupted ...
# N.B. : KeyboardInterrupt does not inherit from Exception
except (KeyboardInterrupt, EOFError):
error = m18n.n("operation_interrupted")
logger.error(m18n.n("config_action_failed", action=action, error=error))
raise
# Something wrong happened in Yunohost's code (most probably hook_exec)
except Exception:
import traceback
error = m18n.n("unexpected_error", error="\n" + traceback.format_exc())
logger.error(m18n.n("config_action_failed", action=action, error=error))
raise
finally:
# Delete files uploaded from API
# FIXME : this is currently done in the context of config panels,
# but could also happen in the context of app install ... (or anywhere else
# where we may parse args etc...)
FileQuestion.clean_upload_dirs()
# FIXME: i18n
logger.success(f"Action {action_id} successful")
operation_logger.success()
def set(
self, key=None, value=None, args=None, args_file=None, operation_logger=None
):
self.filter_key = key or ""
# Read config panel toml
self._get_config_panel()
if not self.config:
raise YunohostValidationError("config_no_panel")
if (args is not None or args_file is not None) and value is not None:
raise YunohostValidationError(
"You should either provide a value, or a serie of args/args_file, but not both at the same time",
raw_msg=True,
)
if self.filter_key.count(".") != 2 and value is not None:
raise YunohostValidationError("config_cant_set_value_on_section")
# Import and parse pre-answered options
logger.debug("Import and parse pre-answered options")
self._parse_pre_answered(args, value, args_file)
# Read or get values and hydrate the config
self._load_current_values()
self._hydrate()
Question.operation_logger = operation_logger
self._ask()
if operation_logger:
operation_logger.start()
try:
self._apply()
except YunohostError:
raise
# Script got manually interrupted ...
# N.B. : KeyboardInterrupt does not inherit from Exception
except (KeyboardInterrupt, EOFError):
error = m18n.n("operation_interrupted")
logger.error(m18n.n("config_apply_failed", error=error))
raise
# Something wrong happened in Yunohost's code (most probably hook_exec)
except Exception:
import traceback
error = m18n.n("unexpected_error", error="\n" + traceback.format_exc())
logger.error(m18n.n("config_apply_failed", error=error))
raise
finally:
# Delete files uploaded from API
# FIXME : this is currently done in the context of config panels,
# but could also happen in the context of app install ... (or anywhere else
# where we may parse args etc...)
FileQuestion.clean_upload_dirs()
self._reload_services()
logger.success("Config updated as expected")
operation_logger.success()
def _get_toml(self):
return read_toml(self.config_path)
def _get_config_panel(self):
# Split filter_key
filter_key = self.filter_key.split(".") if self.filter_key != "" else []
if len(filter_key) > 3:
raise YunohostError(
f"The filter key {filter_key} has too many sub-levels, the max is 3.",
raw_msg=True,
)
if not os.path.exists(self.config_path):
logger.debug(f"Config panel {self.config_path} doesn't exists")
return None
toml_config_panel = self._get_toml()
# Check TOML config panel is in a supported version
if float(toml_config_panel["version"]) < CONFIG_PANEL_VERSION_SUPPORTED:
logger.error(
f"Config panels version {toml_config_panel['version']} are not supported"
)
return None
# Transform toml format into internal format
format_description = {
"root": {
"properties": ["version", "i18n"],
"defaults": {"version": 1.0},
},
"panels": {
"properties": ["name", "services", "actions", "help"],
"defaults": {
"services": [],
"actions": {"apply": {"en": "Apply"}},
},
},
"sections": {
"properties": ["name", "services", "optional", "help", "visible"],
"defaults": {
"name": "",
"services": [],
"optional": True,
"is_action_section": False,
},
},
"options": {
"properties": [
"ask",
"type",
"bind",
"help",
"example",
"default",
"style",
"icon",
"placeholder",
"visible",
"optional",
"choices",
"yes",
"no",
"pattern",
"limit",
"min",
"max",
"step",
"accept",
"redact",
"filter",
"readonly",
"enabled",
# "confirm", # TODO: to ask confirmation before running an action
],
"defaults": {},
},
}
def _build_internal_config_panel(raw_infos, level):
"""Convert TOML in internal format ('full' mode used by webadmin)
Here are some properties of 1.0 config panel in toml:
- node properties and node children are mixed,
- text are in english only
- some properties have default values
This function detects all children nodes and put them in a list
"""
defaults = format_description[level]["defaults"]
properties = format_description[level]["properties"]
# Start building the ouput (merging the raw infos + defaults)
out = {key: raw_infos.get(key, value) for key, value in defaults.items()}
# Now fill the sublevels (+ apply filter_key)
i = list(format_description).index(level)
sublevel = list(format_description)[i + 1] if level != "options" else None
search_key = filter_key[i] if len(filter_key) > i else False
for key, value in raw_infos.items():
# Key/value are a child node
if (
isinstance(value, OrderedDict)
and key not in properties
and sublevel
):
# We exclude all nodes not referenced by the filter_key
if search_key and key != search_key:
continue
subnode = _build_internal_config_panel(value, sublevel)
subnode["id"] = key
if level == "root":
subnode.setdefault("name", {"en": key.capitalize()})
elif level == "sections":
subnode["name"] = key # legacy
subnode.setdefault("optional", raw_infos.get("optional", True))
# If this section contains at least one button, it becomes an "action" section
if subnode.get("type") == "button":
out["is_action_section"] = True
out.setdefault(sublevel, []).append(subnode)
# Key/value are a property
else:
if key not in properties:
logger.warning(f"Unknown key '{key}' found in config panel")
# Todo search all i18n keys
out[key] = (
value if key not in ["ask", "help", "name"] else {"en": value}
)
return out
self.config = _build_internal_config_panel(toml_config_panel, "root")
try:
self.config["panels"][0]["sections"][0]["options"][0]
except (KeyError, IndexError):
raise YunohostValidationError(
"config_unknown_filter_key", filter_key=self.filter_key
)
# List forbidden keywords from helpers and sections toml (to avoid conflict)
forbidden_keywords = [
"old",
"app",
"changed",
"file_hash",
"binds",
"types",
"formats",
"getter",
"setter",
"short_setting",
"type",
"bind",
"nothing_changed",
"changes_validated",
"result",
"max_progression",
]
forbidden_keywords += format_description["sections"]
forbidden_readonly_types = ["password", "app", "domain", "user", "file"]
for _, _, option in self._iterate():
if option["id"] in forbidden_keywords:
raise YunohostError("config_forbidden_keyword", keyword=option["id"])
if (
option.get("readonly", False)
and option.get("type", "string") in forbidden_readonly_types
):
raise YunohostError(
"config_forbidden_readonly_type",
type=option["type"],
id=option["id"],
)
return self.config
def _hydrate(self):
# Hydrating config panel with current value
for _, section, option in self._iterate():
if option["id"] not in self.values:
allowed_empty_types = [
"alert",
"display_text",
"markdown",
"file",
"button",
]
if section["is_action_section"] and option.get("default") is not None:
self.values[option["id"]] = option["default"]
elif (
option["type"] in allowed_empty_types
or option.get("bind") == "null"
):
continue
else:
raise YunohostError(
f"Config panel question '{option['id']}' should be initialized with a value during install or upgrade.",
raw_msg=True,
)
value = self.values[option["name"]]
# Allow to use value instead of current_value in app config script.
# e.g. apps may write `echo 'value: "foobar"'` in the config file (which is more intuitive that `echo 'current_value: "foobar"'`
# For example hotspot used it...
# See https://github.com/YunoHost/yunohost/pull/1546
if (
isinstance(value, dict)
and "value" in value
and "current_value" not in value
):
value["current_value"] = value["value"]
# In general, the value is just a simple value.
# Sometimes it could be a dict used to overwrite the option itself
value = value if isinstance(value, dict) else {"current_value": value}
option.update(value)
self.values[option["id"]] = value.get("current_value")
return self.values
def _ask(self, action=None):
logger.debug("Ask unanswered question and prevalidate data")
if "i18n" in self.config:
for panel, section, option in self._iterate():
if "ask" not in option:
option["ask"] = m18n.n(self.config["i18n"] + "_" + option["id"])
# auto add i18n help text if present in locales
if m18n.key_exists(self.config["i18n"] + "_" + option["id"] + "_help"):
option["help"] = m18n.n(
self.config["i18n"] + "_" + option["id"] + "_help"
)
def display_header(message):
"""CLI panel/section header display"""
if Moulinette.interface.type == "cli" and self.filter_key.count(".") < 2:
Moulinette.display(colorize(message, "purple"))
for panel, section, obj in self._iterate(["panel", "section"]):
if (
section
and section.get("visible")
and not evaluate_simple_js_expression(
section["visible"], context=self.future_values
)
):
continue
# Ugly hack to skip action section ... except when when explicitly running actions
if not action:
if section and section["is_action_section"]:
continue
if panel == obj:
name = _value_for_locale(panel["name"])
display_header(f"\n{'='*40}\n>>>> {name}\n{'='*40}")
else:
name = _value_for_locale(section["name"])
if name:
display_header(f"\n# {name}")
elif section:
# filter action section options in case of multiple buttons
section["options"] = [
option
for option in section["options"]
if option.get("type", "string") != "button"
or option["id"] == action
]
if panel == obj:
continue
# Check and ask unanswered questions
prefilled_answers = self.args.copy()
prefilled_answers.update(self.new_values)
questions = ask_questions_and_parse_answers(
{question["name"]: question for question in section["options"]},
prefilled_answers=prefilled_answers,
current_values=self.values,
hooks=self.hooks,
)
self.new_values.update(
{
question.name: question.value
for question in questions
if question.value is not None
}
)
def _get_default_values(self):
return {
option["id"]: option["default"]
for _, _, option in self._iterate()
if "default" in option
}
@property
def future_values(self):
return {**self.values, **self.new_values}
def __getattr__(self, name):
if "new_values" in self.__dict__ and name in self.new_values:
return self.new_values[name]
if "values" in self.__dict__ and name in self.values:
return self.values[name]
return self.__dict__[name]
def _load_current_values(self):
"""
Retrieve entries in YAML file
And set default values if needed
"""
# Inject defaults if needed (using the magic .update() ;))
self.values = self._get_default_values()
# Retrieve entries in the YAML
if os.path.exists(self.save_path) and os.path.isfile(self.save_path):
self.values.update(read_yaml(self.save_path) or {})
def _parse_pre_answered(self, args, value, args_file):
args = urllib.parse.parse_qs(args or "", keep_blank_values=True)
self.args = {key: ",".join(value_) for key, value_ in args.items()}
if args_file:
# Import YAML / JSON file but keep --args values
self.args = {**read_yaml(args_file), **self.args}
if value is not None:
self.args = {self.filter_key.split(".")[-1]: value}
def _apply(self):
logger.info("Saving the new configuration...")
dir_path = os.path.dirname(os.path.realpath(self.save_path))
if not os.path.exists(dir_path):
mkdir(dir_path, mode=0o700)
values_to_save = self.future_values
if self.save_mode == "diff":
defaults = self._get_default_values()
values_to_save = {
k: v for k, v in values_to_save.items() if defaults.get(k) != v
}
# Save the settings to the .yaml file
write_to_yaml(self.save_path, values_to_save)
def _reload_services(self):
from yunohost.service import service_reload_or_restart
services_to_reload = set()
for panel, section, obj in self._iterate(["panel", "section", "option"]):
services_to_reload |= set(obj.get("services", []))
services_to_reload = list(services_to_reload)
services_to_reload.sort(key="nginx".__eq__)
if services_to_reload:
logger.info("Reloading services...")
for service in services_to_reload:
if hasattr(self, "entity"):
service = service.replace("__APP__", self.entity)
service_reload_or_restart(service)
def _iterate(self, trigger=["option"]):
for panel in self.config.get("panels", []):
if "panel" in trigger:
yield (panel, None, panel)
for section in panel.get("sections", []):
if "section" in trigger:
yield (panel, section, section)
if "option" in trigger:
for option in section.get("options", []):
yield (panel, section, option)
class Question:
hide_user_input_in_prompt = False
pattern: Optional[Dict] = None
@ -856,7 +204,9 @@ class Question:
# Don't restrict choices if there's none specified
self.choices = question.get("choices", None)
self.pattern = question.get("pattern", self.pattern)
self.ask = question.get("ask", {"en": self.name})
self.ask = question.get("ask", self.name)
if not isinstance(self.ask, dict):
self.ask = {"en": self.ask}
self.help = question.get("help")
self.redact = question.get("redact", False)
self.filter = question.get("filter", None)
@ -962,7 +312,7 @@ class Question:
"app_argument_choice_invalid",
name=self.name,
value=self.value,
choices=", ".join(self.choices),
choices=", ".join(str(choice) for choice in self.choices),
)
if self.pattern and not re.match(self.pattern["regexp"], str(self.value)):
raise YunohostValidationError(
@ -1085,13 +435,13 @@ class TagsQuestion(Question):
@staticmethod
def humanize(value, option={}):
if isinstance(value, list):
return ",".join(value)
return ",".join(str(v) for v in value)
return value
@staticmethod
def normalize(value, option={}):
if isinstance(value, list):
return ",".join(value)
return ",".join(str(v) for v in value)
if isinstance(value, str):
value = value.strip()
return value
@ -1102,6 +452,21 @@ class TagsQuestion(Question):
values = values.split(",")
elif values is None:
values = []
if not isinstance(values, list):
if self.choices:
raise YunohostValidationError(
"app_argument_choice_invalid",
name=self.name,
value=self.value,
choices=", ".join(str(choice) for choice in self.choices),
)
raise YunohostValidationError(
"app_argument_invalid",
name=self.name,
error=f"'{str(self.value)}' is not a list",
)
for value in values:
self.value = value
super()._prevalidate()
@ -1152,6 +517,13 @@ class PathQuestion(Question):
def normalize(value, option={}):
option = option.__dict__ if isinstance(option, Question) else option
if not isinstance(value, str):
raise YunohostValidationError(
"app_argument_invalid",
name=option.get("name"),
error="Argument for path should be a string.",
)
if not value.strip():
if option.get("optional"):
return ""
@ -1399,7 +771,7 @@ class NumberQuestion(Question):
return int(value)
if value in [None, ""]:
return value
return None
option = option.__dict__ if isinstance(option, Question) else option
raise YunohostValidationError(
@ -1481,8 +853,12 @@ class FileQuestion(Question):
super()._prevalidate()
# Validation should have already failed if required
if self.value in [None, ""]:
return self.value
if Moulinette.interface.type != "api":
if not self.value or not os.path.exists(str(self.value)):
if not os.path.exists(str(self.value)) or not os.path.isfile(str(self.value)):
raise YunohostValidationError(
"app_argument_invalid",
name=self.name,
@ -1493,7 +869,7 @@ class FileQuestion(Question):
from base64 import b64decode
if not self.value:
return self.value
return ""
upload_dir = tempfile.mkdtemp(prefix="ynh_filequestion_")
_, file_path = tempfile.mkstemp(dir=upload_dir)

View file

@ -258,6 +258,7 @@ ynh_abort_if_errors
# print(ret)
class SourcesResource(AppResource):
"""
Declare what are the sources / assets used by this app. Typically, this corresponds to some tarball published by the upstream project, that needs to be downloaded and extracted in the install dir using the ynh_setup_source helper.
@ -266,7 +267,7 @@ class SourcesResource(AppResource):
Various options are available to accomodate the behavior according to the asset structure
##### Example:
##### Example
```toml
[resources.sources]
@ -274,6 +275,8 @@ class SourcesResource(AppResource):
[resources.sources.main]
url = "https://github.com/foo/bar/archive/refs/tags/v1.2.3.tar.gz"
sha256 = "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b"
autoupdate.strategy = "latest_github_tag"
```
Or more complex examples with several element, including one with asset that depends on the arch
@ -285,11 +288,16 @@ class SourcesResource(AppResource):
in_subdir = false
amd64.url = "https://github.com/foo/bar/archive/refs/tags/v1.2.3.amd64.tar.gz"
amd64.sha256 = "01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b"
i386.url = "https://github.com/foo/bar/archive/refs/tags/v1.2.3.amd64.tar.gz"
i386.url = "https://github.com/foo/bar/archive/refs/tags/v1.2.3.386.tar.gz"
i386.sha256 = "53c234e5e8472b6ac51c1ae1cab3fe06fad053beb8ebfd8977b010655bfdd3c3"
armhf.url = "https://github.com/foo/bar/archive/refs/tags/v1.2.3.armhf.tar.gz"
armhf.url = "https://github.com/foo/bar/archive/refs/tags/v1.2.3.arm.tar.gz"
armhf.sha256 = "4355a46b19d348dc2f57c046f8ef63d4538ebb936000f3c9ee954a27460dd865"
autoupdate.strategy = "latest_github_release"
autoupdate.asset.amd64 = ".*\.amd64.tar.gz"
autoupdate.asset.i386 = ".*\.386.tar.gz"
autoupdate.asset.armhf = ".*\.arm.tar.gz"
[resources.sources.zblerg]
url = "https://zblerg.com/download/zblerg"
sha256 = "1121cfccd5913f0a63fec40a6ffd44ea64f9dc135c66634ba001d10bcf4302a2"
@ -298,7 +306,7 @@ class SourcesResource(AppResource):
```
##### Properties (for each source):
##### Properties (for each source)
- `prefetch` : `true` (default) or `false`, wether or not to pre-fetch this asset during the provisioning phase of the resource. If several arch-dependent url are provided, YunoHost will only prefetch the one for the current system architecture.
- `url` : the asset's URL
@ -315,11 +323,27 @@ class SourcesResource(AppResource):
- `rename`: some string like `whatever_your_want`, to be used for convenience when `extract` is `false` and the default name of the file is not practical
- `platform`: for example `linux/amd64` (defaults to `linux/$YNH_ARCH`) to be used in conjonction with `format = "docker"` to specify which architecture to extract for
###### Regarding `autoupdate`
##### Provision/Update:
Strictly speaking, this has nothing to do with the actual app install. `autoupdate` is expected to contain metadata for automatic maintenance / update of the app sources info in the manifest. It is meant to be a simpler replacement for "autoupdate" Github workflow mechanism.
The infos are used by this script : https://github.com/YunoHost/apps/blob/master/tools/autoupdate_app_sources/autoupdate_app_sources.py which is ran by the YunoHost infrastructure periodically and will create the corresponding pull request automatically.
The script will rely on the code repo specified in the upstream section of the manifest.
`autoupdate.strategy` is expected to be one of :
- `latest_github_tag` : look for the latest tag (by sorting tags and finding the "largest" version). Then using the corresponding tar.gz url. Tags containing `rc`, `beta`, `alpha`, `start` are ignored, and actually any tag which doesn't look like `x.y.z` or `vx.y.z`
- `latest_github_release` : similar to `latest_github_tags`, but starting from the list of releases. Pre- or draft releases are ignored. Releases may have assets attached to them, in which case you can define:
- `autoupdate.asset = "some regex"` (when there's only one asset to use). The regex is used to find the appropriate asset among the list of all assets
- or several `autoupdate.asset.$arch = "some_regex"` (when the asset is arch-specific). The regex is used to find the appropriate asset for the specific arch among the list of assets
- `latest_github_commit` : will use the latest commit on github, and the corresponding tarball. If this is used for the 'main' source, it will also assume that the version is YYYY.MM.DD corresponding to the date of the commit.
It is also possible to define `autoupdate.upstream` to use a different Git(hub) repository instead of the code repository from the upstream section of the manifest. This can be useful when, for example, the app uses other assets such as plugin from a different repository.
##### Provision/Update
- For elements with `prefetch = true`, will download the asset (for the appropriate architecture) and store them in `/var/cache/yunohost/download/$app/$source_id`, to be later picked up by `ynh_setup_source`. (NB: this only happens during install and upgrade, not restore)
##### Deprovision:
##### Deprovision
- Nothing (just cleanup the cache)
"""
@ -335,7 +359,6 @@ class SourcesResource(AppResource):
sources: Dict[str, Dict[str, Any]] = {}
def __init__(self, properties: Dict[str, Any], *args, **kwargs):
for source_id, infos in properties.items():
properties[source_id] = copy.copy(self.default_sources_properties)
properties[source_id].update(infos)
@ -347,29 +370,37 @@ class SourcesResource(AppResource):
rm(f"/var/cache/yunohost/download/{self.app}/", recursive=True)
def provision_or_update(self, context: Dict = {}):
# Don't prefetch stuff during restore
if context.get("action") == "restore":
return
for source_id, infos in self.sources.items():
if not infos["prefetch"]:
continue
if infos["url"] is None:
arch = system_arch()
if arch in infos and isinstance(infos[arch], dict) and isinstance(infos[arch].get("url"), str) and isinstance(infos[arch].get("sha256"), str):
if (
arch in infos
and isinstance(infos[arch], dict)
and isinstance(infos[arch].get("url"), str)
and isinstance(infos[arch].get("sha256"), str)
):
self.prefetch(source_id, infos[arch]["url"], infos[arch]["sha256"])
else:
raise YunohostError(f"In resources.sources: it looks like you forgot to define url/sha256 or {arch}.url/{arch}.sha256", raw_msg=True)
raise YunohostError(
f"In resources.sources: it looks like you forgot to define url/sha256 or {arch}.url/{arch}.sha256",
raw_msg=True,
)
else:
if infos["sha256"] is None:
raise YunohostError(f"In resources.sources: it looks like the sha256 is missing for {source_id}", raw_msg=True)
raise YunohostError(
f"In resources.sources: it looks like the sha256 is missing for {source_id}",
raw_msg=True,
)
self.prefetch(source_id, infos["url"], infos["sha256"])
def prefetch(self, source_id, url, expected_sha256):
logger.debug(f"Prefetching asset {source_id}: {url} ...")
if not os.path.isdir(f"/var/cache/yunohost/download/{self.app}/"):
@ -378,21 +409,49 @@ class SourcesResource(AppResource):
# NB: we use wget and not requests.get() because we want to output to a file (ie avoid ending up with the full archive in RAM)
# AND the nice --tries, --no-dns-cache, --timeout options ...
p = subprocess.Popen(["/usr/bin/wget", "--tries=3", "--no-dns-cache", "--timeout=900", "--no-verbose", "--output-document=" + filename, url], stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
p = subprocess.Popen(
[
"/usr/bin/wget",
"--tries=3",
"--no-dns-cache",
"--timeout=900",
"--no-verbose",
"--output-document=" + filename,
url,
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
)
out, _ = p.communicate()
returncode = p.returncode
if returncode != 0:
if os.path.exists(filename):
rm(filename)
raise YunohostError("app_failed_to_download_asset", source_id=source_id, url=url, app=self.app, out=out.decode())
raise YunohostError(
"app_failed_to_download_asset",
source_id=source_id,
url=url,
app=self.app,
out=out.decode(),
)
assert os.path.exists(filename), f"For some reason, wget worked but {filename} doesnt exists?"
assert os.path.exists(
filename
), f"For some reason, wget worked but {filename} doesnt exists?"
computed_sha256 = check_output(f"sha256sum {filename}").split()[0]
if computed_sha256 != expected_sha256:
size = check_output(f"du -hs {filename}").split()[0]
rm(filename)
raise YunohostError("app_corrupt_source", source_id=source_id, url=url, app=self.app, expected_sha256=expected_sha256, computed_sha256=computed_sha256, size=size)
raise YunohostError(
"app_corrupt_source",
source_id=source_id,
url=url,
app=self.app,
expected_sha256=expected_sha256,
computed_sha256=computed_sha256,
size=size,
)
class PermissionsResource(AppResource):
@ -403,7 +462,7 @@ class PermissionsResource(AppResource):
The list of allowed user/groups may be initialized using the content of the `init_{perm}_permission` question from the manifest, hence `init_main_permission` replaces the `is_public` question and shall contain a group name (typically, `all_users` or `visitors`).
##### Example:
##### Example
```toml
[resources.permissions]
main.url = "/"
@ -414,7 +473,7 @@ class PermissionsResource(AppResource):
admin.allowed = "admins" # Assuming the "admins" group exists (cf future developments ;))
```
##### Properties (for each perm name):
##### Properties (for each perm name)
- `url`: The relative URI corresponding to this permission. Typically `/` or `/something`. This property may be omitted for non-web permissions.
- `show_tile`: (default: `true` if `url` is defined) Wether or not a tile should be displayed for that permission in the user portal
- `allowed`: (default: nobody) The group initially allowed to access this perm, if `init_{perm}_permission` is not defined in the manifest questions. Note that the admin may tweak who is allowed/unallowed on that permission later on, this is only meant to **initialize** the permission.
@ -422,14 +481,14 @@ class PermissionsResource(AppResource):
- `protected`: (default: `false`) Define if this permission is protected. If it is protected the administrator won't be able to add or remove the visitors group of this permission. Defaults to 'false'.
- `additional_urls`: (default: none) List of additional URL for which access will be allowed/forbidden
##### Provision/Update:
##### Provision/Update
- Delete any permissions that may exist and be related to this app yet is not declared anymore
- Loop over the declared permissions and create them if needed or update them with the new values
##### Deprovision:
##### Deprovision
- Delete all permission related to this app
##### Legacy management:
##### Legacy management
- Legacy `is_public` setting will be deleted if it exists
"""
@ -461,6 +520,31 @@ class PermissionsResource(AppResource):
properties["main"] = self.default_perm_properties
for perm, infos in properties.items():
if "auth_header" in infos and not isinstance(
infos.get("auth_header"), bool
):
raise YunohostError(
f"In manifest, for permission '{perm}', 'auth_header' should be a boolean",
raw_msg=True,
)
if "show_tile" in infos and not isinstance(infos.get("show_tile"), bool):
raise YunohostError(
f"In manifest, for permission '{perm}', 'show_tile' should be a boolean",
raw_msg=True,
)
if "protected" in infos and not isinstance(infos.get("protected"), bool):
raise YunohostError(
f"In manifest, for permission '{perm}', 'protected' should be a boolean",
raw_msg=True,
)
if "additional_urls" in infos and not isinstance(
infos.get("additional_urls"), list
):
raise YunohostError(
f"In manifest, for permission '{perm}', 'additional_urls' should be a list",
raw_msg=True,
)
properties[perm] = copy.copy(self.default_perm_properties)
properties[perm].update(infos)
if properties[perm]["show_tile"] is None:
@ -581,22 +665,22 @@ class SystemuserAppResource(AppResource):
"""
Provision a system user to be used by the app. The username is exactly equal to the app id
##### Example:
##### Example
```toml
[resources.system_user]
# (empty - defaults are usually okay)
```
##### Properties:
##### Properties
- `allow_ssh`: (default: False) Adds the user to the ssh.app group, allowing SSH connection via this user
- `allow_sftp`: (default: False) Adds the user to the sftp.app group, allowing SFTP connection via this user
- `home`: (default: `/var/www/__APP__`) Defines the home property for this user. NB: unfortunately you can't simply use `__INSTALL_DIR__` or `__DATA_DIR__` for now
##### Provision/Update:
##### Provision/Update
- will create the system user if it doesn't exists yet
- will add/remove the ssh/sftp.app groups
##### Deprovision:
##### Deprovision
- deletes the user and group
"""
@ -689,28 +773,28 @@ class InstalldirAppResource(AppResource):
"""
Creates a directory to be used by the app as the installation directory, typically where the app sources and assets are located. The corresponding path is stored in the settings as `install_dir`
##### Example:
##### Example
```toml
[resources.install_dir]
# (empty - defaults are usually okay)
```
##### Properties:
##### Properties
- `dir`: (default: `/var/www/__APP__`) The full path of the install dir
- `owner`: (default: `__APP__:rwx`) The owner (and owner permissions) for the install dir
- `group`: (default: `__APP__:rx`) The group (and group permissions) for the install dir
##### Provision/Update:
##### Provision/Update
- during install, the folder will be deleted if it already exists (FIXME: is this what we want?)
- if the dir path changed and a folder exists at the old location, the folder will be `mv`'ed to the new location
- otherwise, creates the directory if it doesn't exists yet
- (re-)apply permissions (only on the folder itself, not recursively)
- save the value of `dir` as `install_dir` in the app's settings, which can be then used by the app scripts (`$install_dir`) and conf templates (`__INSTALL_DIR__`)
##### Deprovision:
##### Deprovision
- recursively deletes the directory if it exists
##### Legacy management:
##### Legacy management
- In the past, the setting was called `final_path`. The code will automatically rename it as `install_dir`.
- As explained in the 'Provision/Update' section, the folder will also be moved if the location changed
@ -804,28 +888,30 @@ class DatadirAppResource(AppResource):
"""
Creates a directory to be used by the app as the data store directory, typically where the app multimedia or large assets added by users are located. The corresponding path is stored in the settings as `data_dir`. This resource behaves very similarly to install_dir.
##### Example:
##### Example
```toml
[resources.data_dir]
# (empty - defaults are usually okay)
```
##### Properties:
##### Properties
- `dir`: (default: `/home/yunohost.app/__APP__`) The full path of the data dir
- `subdirs`: (default: empty list) A list of subdirs to initialize inside the data dir. For example, `['foo', 'bar']`
- `owner`: (default: `__APP__:rwx`) The owner (and owner permissions) for the data dir
- `group`: (default: `__APP__:rx`) The group (and group permissions) for the data dir
##### Provision/Update:
##### Provision/Update
- if the dir path changed and a folder exists at the old location, the folder will be `mv`'ed to the new location
- otherwise, creates the directory if it doesn't exists yet
- (re-)apply permissions (only on the folder itself, not recursively)
- create each subdir declared and which do not exist already
- (re-)apply permissions (only on the folder itself and declared subdirs, not recursively)
- save the value of `dir` as `data_dir` in the app's settings, which can be then used by the app scripts (`$data_dir`) and conf templates (`__DATA_DIR__`)
##### Deprovision:
##### Deprovision
- (only if the purge option is chosen by the user) recursively deletes the directory if it exists
- also delete the corresponding setting
##### Legacy management:
##### Legacy management
- In the past, the setting may have been called `datadir`. The code will automatically rename it as `data_dir`.
- As explained in the 'Provision/Update' section, the folder will also be moved if the location changed
@ -841,11 +927,13 @@ class DatadirAppResource(AppResource):
default_properties: Dict[str, Any] = {
"dir": "/home/yunohost.app/__APP__",
"subdirs": [],
"owner": "__APP__:rwx",
"group": "__APP__:rx",
}
dir: str = ""
subdirs: list = []
owner: str = ""
group: str = ""
@ -867,7 +955,12 @@ class DatadirAppResource(AppResource):
)
shutil.move(current_data_dir, self.dir)
else:
mkdir(self.dir)
mkdir(self.dir, parents=True)
for subdir in self.subdirs:
full_path = os.path.join(self.dir, subdir)
if not os.path.isdir(full_path):
mkdir(full_path, parents=True)
owner, owner_perm = self.owner.split(":")
group, group_perm = self.group.split(":")
@ -887,6 +980,10 @@ class DatadirAppResource(AppResource):
# in which case we want to apply the perm to the pointed dir, not to the symlink
chmod(os.path.realpath(self.dir), perm_octal)
chown(os.path.realpath(self.dir), owner, group)
for subdir in self.subdirs:
full_path = os.path.join(self.dir, subdir)
chmod(os.path.realpath(full_path), perm_octal)
chown(os.path.realpath(full_path), owner, group)
self.set_setting("data_dir", self.dir)
self.delete_setting("datadir") # Legacy
@ -906,7 +1003,7 @@ class AptDependenciesAppResource(AppResource):
"""
Create a virtual package in apt, depending on the list of specified packages that the app needs. The virtual packages is called `$app-ynh-deps` (with `_` being replaced by `-` in the app name, see `ynh_install_app_dependencies`)
##### Example:
##### Example
```toml
[resources.apt]
packages = "nyancat, lolcat, sl"
@ -917,16 +1014,16 @@ class AptDependenciesAppResource(AppResource):
extras.yarn.packages = "yarn"
```
##### Properties:
##### Properties
- `packages`: Comma-separated list of packages to be installed via `apt`
- `packages_from_raw_bash`: A multi-line bash snippet (using triple quotes as open/close) which should echo additional packages to be installed. Meant to be used for packages to be conditionally installed depending on architecture, debian version, install questions, or other logic.
- `extras`: A dict of (repo, key, packages) corresponding to "extra" repositories to fetch dependencies from
##### Provision/Update:
##### Provision/Update
- The code literally calls the bash helpers `ynh_install_app_dependencies` and `ynh_install_extra_app_dependencies`, similar to what happens in v1.
- Note that when `packages` contains some phpX.Y-foobar dependencies, this will automagically define a `phpversion` setting equal to `X.Y` which can therefore be used in app scripts ($phpversion) or templates (`__PHPVERSION__`)
##### Deprovision:
##### Deprovision
- The code literally calls the bash helper `ynh_remove_app_dependencies`
"""
@ -985,7 +1082,7 @@ class PortsResource(AppResource):
Note that because multiple ports can be booked, each properties is prefixed by the name of the port. `main` is a special name and will correspond to the setting `$port`, whereas for example `xmpp_client` will correspond to the setting `$port_xmpp_client`.
##### Example:
##### Example
```toml
[resources.ports]
# (empty should be fine for most apps... though you can customize stuff if absolutely needed)
@ -997,21 +1094,21 @@ class PortsResource(AppResource):
xmpp_client.exposed = "TCP" # here, we're telling that the port needs to be publicly exposed on TCP on the firewall
```
##### Properties (for every port name):
##### Properties (for every port name)
- `default`: The prefered value for the port. If this port is already being used by another process right now, or is booked in another app's setting, the code will increment the value until it finds a free port and store that value as the setting. If no value is specified, a random value between 10000 and 60000 is used.
- `exposed`: (default: `false`) Wether this port should be opened on the firewall and be publicly reachable. This should be kept to `false` for the majority of apps than only need a port for internal reverse-proxying! Possible values: `false`, `true`(=`Both`), `Both`, `TCP`, `UDP`. This will result in the port being opened on the firewall, and the diagnosis checking that a program answers on that port.
- `fixed`: (default: `false`) Tells that the app absolutely needs the specific value provided in `default`, typically because it's needed for a specific protocol
##### Provision/Update (for every port name):
##### Provision/Update (for every port name)
- If not already booked, look for a free port, starting with the `default` value (or a random value between 10000 and 60000 if no `default` set)
- If `exposed` is not `false`, open the port in the firewall accordingly - otherwise make sure it's closed.
- The value of the port is stored in the `$port` setting for the `main` port, or `$port_NAME` for other `NAME`s
##### Deprovision:
##### Deprovision
- Close the ports on the firewall if relevant
- Deletes all the port settings
##### Legacy management:
##### Legacy management
- In the past, some settings may have been named `NAME_port` instead of `port_NAME`, in which case the code will automatically rename the old setting.
"""
@ -1114,25 +1211,25 @@ class DatabaseAppResource(AppResource):
NB2: no automagic migration will happen in an suddenly change `type` from `mysql` to `postgresql` or viceversa in its life
##### Example:
##### Example
```toml
[resources.database]
type = "mysql" # or : "postgresql". Only these two values are supported
```
##### Properties:
##### Properties
- `type`: The database type, either `mysql` or `postgresql`
##### Provision/Update:
##### Provision/Update
- (Re)set the `$db_name` and `$db_user` settings with the sanitized app name (replacing `-` and `.` with `_`)
- If `$db_pwd` doesn't already exists, pick a random database password and store it in that setting
- If the database doesn't exists yet, create the SQL user and DB using `ynh_mysql_create_db` or `ynh_psql_create_db`.
##### Deprovision:
##### Deprovision
- Drop the DB using `ynh_mysql_remove_db` or `ynh_psql_remove_db`
- Deletes the `db_name`, `db_user` and `db_pwd` settings
##### Legacy management:
##### Legacy management
- In the past, the sql passwords may have been named `mysqlpwd` or `psqlpwd`, in which case it will automatically be renamed as `db_pwd`
"""