mirror of
https://github.com/YunoHost/yunohost.git
synced 2024-09-03 20:06:10 +02:00
Merge branch 'dev' of https://github.com/YunoHost/yunohost into dev
This commit is contained in:
commit
6bd8da807e
28 changed files with 4546 additions and 2545 deletions
78
.github/workflows/n_updater.sh
vendored
78
.github/workflows/n_updater.sh
vendored
|
@ -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
|
3
.github/workflows/n_updater.yml
vendored
3
.github/workflows/n_updater.yml
vendored
|
@ -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
37
debian/changelog
vendored
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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}"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
21
helpers/vendor/n/LICENSE
vendored
Normal 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
1
helpers/vendor/n/README.md
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
This is taken from https://github.com/tj/n/
|
1621
helpers/vendor/n/n
vendored
Executable file
1621
helpers/vendor/n/n
vendored
Executable file
File diff suppressed because it is too large
Load diff
|
@ -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 غير محظور)."
|
||||
}
|
||||
}
|
|
@ -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}"
|
||||
}
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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}"
|
||||
}
|
||||
}
|
|
@ -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 d’autres 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 d’autres aspèctes ligats amb la seguretat)"
|
||||
}
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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}')"
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
@ -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
694
src/utils/configpanel.py
Normal 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)
|
|
@ -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)
|
|
@ -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`
|
||||
"""
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue