diff --git a/.coveragerc b/.coveragerc index fe22c8381..bc952e665 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,2 +1,2 @@ [report] -omit=src/tests/*,src/vendor/*,/usr/lib/moulinette/yunohost/* +omit=src/tests/*,src/vendor/*,/usr/lib/moulinette/yunohost/*,/usr/lib/python3/dist-packages/yunohost/tests/*,/usr/lib/python3/dist-packages/yunohost/vendor/* diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..d9a548b3b --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,40 @@ +name: "CodeQL" + +on: + push: + branches: [ "dev" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "dev" ] + schedule: + - cron: '43 12 * * 3' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v2 + with: + languages: ${{ matrix.language }} + queries: security-extended,security-and-quality + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v2 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/n_updater.sh b/.github/workflows/n_updater.sh deleted file mode 100644 index a8b0b0eec..000000000 --- a/.github/workflows/n_updater.sh +++ /dev/null @@ -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 diff --git a/.github/workflows/n_updater.yml b/.github/workflows/n_updater.yml index 35afd8ae7..ce3e9c925 100644 --- a/.github/workflows/n_updater.yml +++ b/.github/workflows/n_updater.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Fetch the source code - uses: actions/checkout@v2 + uses: actions/checkout@v3 with: token: ${{ secrets.GITHUB_TOKEN }} - name: Run the updater script @@ -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' }} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 4d0f30679..3e030940b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -16,6 +16,9 @@ default: code_quality: tags: - docker + rules: + - if: $CI_COMMIT_TAG # Only for tags + code_quality_html: extends: code_quality @@ -23,6 +26,9 @@ code_quality_html: REPORT_FORMAT: html artifacts: paths: [gl-code-quality-report.html] + rules: + - if: $CI_COMMIT_TAG # Only for tags + # see: https://docs.gitlab.com/ee/ci/yaml/#switch-between-branch-pipelines-and-merge-request-pipelines workflow: @@ -37,7 +43,7 @@ workflow: - when: always variables: - YNH_BUILD_DIR: "ynh-build" + YNH_BUILD_DIR: "/ynh-build" include: - template: Code-Quality.gitlab-ci.yml diff --git a/.gitlab/ci/build.gitlab-ci.yml b/.gitlab/ci/build.gitlab-ci.yml index db691b9d2..610580dac 100644 --- a/.gitlab/ci/build.gitlab-ci.yml +++ b/.gitlab/ci/build.gitlab-ci.yml @@ -8,7 +8,7 @@ - DEBIAN_FRONTEND=noninteractive apt update artifacts: paths: - - $YNH_BUILD_DIR/*.deb + - ./*.deb .build_script: &build_script - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" install devscripts --no-install-recommends @@ -17,6 +17,8 @@ - VERSION_NIGHTLY="${VERSION}+$(date +%Y%m%d%H%M)" - dch --package "${PACKAGE}" --force-bad-version -v "${VERSION_NIGHTLY}" -D "unstable" --force-distribution "Daily build." - debuild --no-lintian -us -uc + - cp $YNH_BUILD_DIR/*.deb ${CI_PROJECT_DIR}/ + - cd ${CI_PROJECT_DIR} ######################################## # BUILD DEB @@ -31,7 +33,7 @@ build-yunohost: - mkdir -p $YNH_BUILD_DIR/$PACKAGE - cat archive.tar.gz | tar -xz -C $YNH_BUILD_DIR/$PACKAGE - rm archive.tar.gz - - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $(pwd)/$YNH_BUILD_DIR/$PACKAGE + - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $YNH_BUILD_DIR/$PACKAGE - *build_script @@ -42,7 +44,7 @@ build-ssowat: script: - DEBIAN_DEPENDS=$(cat debian/control | tr "," "\n" | grep -Po "ssowat \([>,=,<]+ .*\)" | grep -Po "[0-9\.]+") - git clone $YNH_SOURCE/$PACKAGE -b $CI_COMMIT_REF_NAME $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE -b $DEBIAN_DEPENDS $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE $YNH_BUILD_DIR/$PACKAGE --depth 1 - - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $(pwd)/$YNH_BUILD_DIR/$PACKAGE + - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $YNH_BUILD_DIR/$PACKAGE - *build_script build-moulinette: @@ -52,5 +54,5 @@ build-moulinette: script: - DEBIAN_DEPENDS=$(cat debian/control | tr "," "\n" | grep -Po "moulinette \([>,=,<]+ .*\)" | grep -Po "[0-9\.]+") - git clone $YNH_SOURCE/$PACKAGE -b $CI_COMMIT_REF_NAME $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE -b $DEBIAN_DEPENDS $YNH_BUILD_DIR/$PACKAGE --depth 1 || git clone $YNH_SOURCE/$PACKAGE $YNH_BUILD_DIR/$PACKAGE --depth 1 - - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $(pwd)/$YNH_BUILD_DIR/$PACKAGE + - DEBIAN_FRONTEND=noninteractive apt --assume-yes -o Dpkg::Options::="--force-confold" build-dep $YNH_BUILD_DIR/$PACKAGE - *build_script diff --git a/.gitlab/ci/doc.gitlab-ci.yml b/.gitlab/ci/doc.gitlab-ci.yml index 528d8f5aa..4f6ea6ba1 100644 --- a/.gitlab/ci/doc.gitlab-ci.yml +++ b/.gitlab/ci/doc.gitlab-ci.yml @@ -13,15 +13,18 @@ generate-helpers-doc: script: - cd doc - python3 generate_helper_doc.py + - python3 generate_resource_doc.py > resources.md - hub clone https://$GITHUB_TOKEN:x-oauth-basic@github.com/YunoHost/doc.git doc_repo - - cp helpers.md doc_repo/pages/06.contribute/10.packaging_apps/11.helpers/packaging_apps_helpers.md + - cp helpers.md doc_repo/pages/06.contribute/10.packaging_apps/80.resources/11.helpers/packaging_apps_helpers.md + - cp resources.md doc_repo/pages/06.contribute/10.packaging_apps/80.resources/15.appresources/packaging_apps_resources.md - cd doc_repo # replace ${CI_COMMIT_REF_NAME} with ${CI_COMMIT_TAG} ? - hub checkout -b "${CI_COMMIT_REF_NAME}" - - hub commit -am "[CI] Helper for ${CI_COMMIT_REF_NAME}" - - hub pull-request -m "[CI] Helper for ${CI_COMMIT_REF_NAME}" -p # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd + - hub commit -am "[CI] Update app helpers/resources for ${CI_COMMIT_REF_NAME}" + - hub pull-request -m "[CI] Update app helpers/resources for ${CI_COMMIT_REF_NAME}" -p # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd artifacts: paths: - doc/helpers.md + - doc/resources.md only: - tags diff --git a/.gitlab/ci/install.gitlab-ci.yml b/.gitlab/ci/install.gitlab-ci.yml index ecdfecfcd..65409c6eb 100644 --- a/.gitlab/ci/install.gitlab-ci.yml +++ b/.gitlab/ci/install.gitlab-ci.yml @@ -17,7 +17,7 @@ upgrade: image: "after-install" script: - apt-get update -o Acquire::Retries=3 - - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ./$YNH_BUILD_DIR/*.deb + - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ${CI_PROJECT_DIR}/*.deb install-postinstall: @@ -25,5 +25,5 @@ install-postinstall: image: "before-install" script: - apt-get update -o Acquire::Retries=3 - - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ./$YNH_BUILD_DIR/*.deb + - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ${CI_PROJECT_DIR}/*.deb - yunohost tools postinstall -d domain.tld -u syssa -F 'Syssa Mine' -p the_password --ignore-dyndns --force-diskspace diff --git a/.gitlab/ci/lint.gitlab-ci.yml b/.gitlab/ci/lint.gitlab-ci.yml index 2c2bdcc1d..7a8fbf1fb 100644 --- a/.gitlab/ci/lint.gitlab-ci.yml +++ b/.gitlab/ci/lint.gitlab-ci.yml @@ -42,7 +42,6 @@ black: - '[ $(git diff | wc -l) != 0 ] || exit 0' # stop if there is nothing to commit - git commit -am "[CI] Format code with Black" || true - git push -f origin "ci-format-${CI_COMMIT_REF_NAME}":"ci-format-${CI_COMMIT_REF_NAME}" - - hub pull-request -m "[CI] Format code with Black" -b Yunohost:$CI_COMMIT_REF_NAME -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd + - hub pull-request -m "[CI] Format code with Black" -b Yunohost:dev -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd only: - variables: - - $CI_COMMIT_REF_NAME == $CI_DEFAULT_BRANCH + - tags diff --git a/.gitlab/ci/test.gitlab-ci.yml b/.gitlab/ci/test.gitlab-ci.yml index 804940aa2..b0ffd3db5 100644 --- a/.gitlab/ci/test.gitlab-ci.yml +++ b/.gitlab/ci/test.gitlab-ci.yml @@ -1,6 +1,6 @@ .install_debs: &install_debs - apt-get update -o Acquire::Retries=3 - - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ./$YNH_BUILD_DIR/*.deb + - DEBIAN_FRONTEND=noninteractive SUDO_FORCE_REMOVE=yes apt --assume-yes -o Dpkg::Options::="--force-confold" --allow-downgrades install ${CI_PROJECT_DIR}/*.deb - pip3 install -U mock pip pytest pytest-cov pytest-mock pytest-sugar requests-mock tox ansi2html black jinja2 "packaging<22" .test-stage: @@ -36,7 +36,7 @@ full-tests: - *install_debs - yunohost tools postinstall -d domain.tld -u syssa -F 'Syssa Mine' -p the_password --ignore-dyndns --force-diskspace script: - - python3 -m pytest --cov=yunohost tests/ src/tests/ src/diagnosers/ --junitxml=report.xml + - python3 -m pytest --cov=yunohost tests/ src/tests/ --junitxml=report.xml - cd tests - bash test_helpers.sh needs: @@ -46,6 +46,7 @@ full-tests: artifacts: true - job: build-moulinette artifacts: true + coverage: '/TOTAL.*\s+(\d+%)/' artifacts: reports: junit: report.xml diff --git a/.gitlab/ci/translation.gitlab-ci.yml b/.gitlab/ci/translation.gitlab-ci.yml index b6c683f57..83db2b5a4 100644 --- a/.gitlab/ci/translation.gitlab-ci.yml +++ b/.gitlab/ci/translation.gitlab-ci.yml @@ -26,7 +26,7 @@ autofix-translated-strings: - git checkout -b "ci-autofix-translated-strings-${CI_COMMIT_REF_NAME}" --no-track - python3 maintenance/missing_i18n_keys.py --fix - python3 maintenance/autofix_locale_format.py - - '[ $(git diff | wc -l) != 0 ] || exit 0' # stop if there is nothing to commit + - '[ $(git diff --ignore-blank-lines --ignore-all-space --ignore-space-at-eol --ignore-cr-at-eol | wc -l) != 0 ] || exit 0' # stop if there is nothing to commit - git commit -am "[CI] Reformat / remove stale translated strings" || true - git push -f origin "ci-autofix-translated-strings-${CI_COMMIT_REF_NAME}":"ci-remove-stale-translated-strings-${CI_COMMIT_REF_NAME}" - hub pull-request -m "[CI] Reformat / remove stale translated strings" -b Yunohost:$CI_COMMIT_REF_NAME -p || true # GITHUB_USER and GITHUB_TOKEN registered here https://gitlab.com/yunohost/yunohost/-/settings/ci_cd diff --git a/.lgtm.yml b/.lgtm.yml deleted file mode 100644 index 8fd57e49e..000000000 --- a/.lgtm.yml +++ /dev/null @@ -1,4 +0,0 @@ -extraction: - python: - python_setup: - version: "3" \ No newline at end of file diff --git a/README.md b/README.md index 5d37b2af1..07ee04de0 100644 --- a/README.md +++ b/README.md @@ -7,10 +7,10 @@
{item}
مُدرَج ضمن قائمة سوداء على {blacklist_name}",
+ "diagnosis_mail_outgoing_port_25_ok": "خادم بريد SMTP قادر على إرسال رسائل البريد الإلكتروني (منفذ البريد الصادر 25 غير محظور)."
+}
\ No newline at end of file
diff --git a/locales/ca.json b/locales/ca.json
index 106d0af89..821e5c3eb 100644
--- a/locales/ca.json
+++ b/locales/ca.json
@@ -165,7 +165,6 @@
"log_available_on_yunopaste": "Aquest registre està disponible via {url}",
"log_backup_restore_system": "Restaura el sistema a partir d'una còpia de seguretat",
"log_backup_restore_app": "Restaura « {} » a partir d'una còpia de seguretat",
- "log_remove_on_failed_restore": "Elimina « {} » després de que la restauració a partir de la còpia de seguretat hagi fallat",
"log_remove_on_failed_install": "Elimina « {} » després de que la instal·lació hagi fallat",
"log_domain_add": "Afegir el domini « {} » a la configuració del sistema",
"log_domain_remove": "Elimina el domini « {} » de la configuració del sistema",
diff --git a/locales/de.json b/locales/de.json
index 5baa41687..b61d0a431 100644
--- a/locales/de.json
+++ b/locales/de.json
@@ -417,7 +417,6 @@
"domain_cannot_remove_main_add_new_one": "Sie können '{domain}' nicht entfernen, da es die Hauptdomäne und Ihre einzige Domäne ist. Sie müssen zuerst eine andere Domäne mit 'yunohost domain add /etc/resolv.conf
.",
"diagnosis_ip_weird_resolvconf_details": "The file /etc/resolv.conf
should be a symlink to /etc/resolvconf/run/resolv.conf
itself pointing to 127.0.0.1
(dnsmasq). If you want to manually configure DNS resolvers, please edit /etc/resolv.dnsmasq.conf
.",
@@ -318,8 +328,8 @@
"diagnosis_using_yunohost_testing_details": "This is probably OK if you know what you are doing, but pay attention to the release notes before installing YunoHost upgrades! If you want to disable 'testing' upgrades, you should remove the {global}
",
"diagnosis_mail_outgoing_port_25_ok": "El servidor de email SMTP puede mandar emails (puerto saliente 25 no está bloqueado).",
@@ -552,7 +551,6 @@
"config_validate_email": "Debe ser una dirección de correo correcta",
"config_validate_time": "Debe ser una hora valida en formato HH:MM",
"config_validate_url": "Debe ser una URL válida",
- "config_version_not_supported": "Las versiones del panel de configuración '{version}' no están soportadas.",
"domain_remove_confirm_apps_removal": "La supresión de este dominio también eliminará las siguientes aplicaciones:\n{apps}\n\n¿Seguro? [{answers}]",
"domain_registrar_is_not_configured": "El registrador aún no ha configurado el dominio {domain}.",
"diagnosis_apps_not_in_app_catalog": "Esta aplicación se encuentra ausente o ya no figura en el catálogo de aplicaciones de YunoHost. Deberías considerar desinstalarla ya que no recibirá actualizaciones y podría comprometer la integridad y seguridad de tu sistema.",
@@ -574,7 +572,6 @@
"domain_dns_push_failed_to_authenticate": "No se pudo autenticar en la API del registrador para el dominio '{domain}'. ¿Lo más probable es que las credenciales sean incorrectas? (Error: {error})",
"domain_dns_registrar_experimental": "Hasta ahora, la comunidad de YunoHost no ha probado ni revisado correctamente la interfaz con la API de **{registrar}**. El soporte es **muy experimental**. ¡Ten cuidado!",
"domain_dns_push_record_failed": "No se pudo {action} registrar {type}/{name}: {error}",
- "domain_config_features_disclaimer": "Hasta ahora, habilitar/deshabilitar las funciones de correo o XMPP solo afecta la configuración de DNS recomendada y automática, ¡no las configuraciones del sistema!",
"domain_config_mail_in": "Correos entrantes",
"domain_config_mail_out": "Correos salientes",
"domain_config_xmpp": "Mensajería instantánea (XMPP)",
@@ -600,7 +597,7 @@
"postinstall_low_rootfsspace": "El sistema de archivos raíz tiene un espacio total inferior a 10 GB, ¡lo cual es bastante preocupante! ¡Es probable que se quede sin espacio en disco muy rápidamente! Se recomienda tener al menos 16 GB para el sistema de archivos raíz. Si desea instalar YunoHost a pesar de esta advertencia, vuelva a ejecutar la instalación posterior con --force-diskspace",
"migration_ldap_rollback_success": "Sistema revertido.",
"permission_protected": "Permiso {permission} está protegido. No puede agregar o quitar el grupo de visitantes a/desde este permiso.",
- "global_settings_setting_ssowat_panel_overlay_enabled": "Habilitar la superposición del panel SSOwat",
+ "global_settings_setting_ssowat_panel_overlay_enabled": "Habilitar el pequeño cuadrado de acceso directo al portal \"YunoHost\" en las aplicaciones",
"migration_0021_start": "Iniciando migración a Bullseye",
"migration_0021_patching_sources_list": "Parcheando los sources.lists...",
"migration_0021_main_upgrade": "Iniciando actualización principal...",
@@ -679,5 +676,76 @@
"config_action_failed": "Error al ejecutar la acción '{action}': {error}",
"config_forbidden_readonly_type": "El tipo '{type}' no puede establecerse como solo lectura, utilice otro tipo para representar este valor (arg id relevante: '{id}').",
"diagnosis_using_stable_codename": "{global}
",
"app_argument_password_no_default": "Errorea egon da '{name}' pasahitzaren argumentua ikuskatzean: pasahitzak ezin du balio hori izan segurtasun arrazoiengatik",
"app_extraction_failed": "Ezinezkoa izan da instalazio fitxategiak ateratzea",
- "backup_deleted": "Babeskopia ezabatuta",
+ "backup_deleted": "Babeskopia ezabatu da: {name}",
"app_argument_required": "'{name}' argumentua ezinbestekoa da",
"certmanager_acme_not_configured_for_domain": "Une honetan ezinezkoa da ACME azterketa {domain} domeinurako burutzea nginx ezarpenek ez dutelako beharrezko kodea… Egiaztatu nginx ezarpenak egunean daudela 'yunohost tools regen-conf nginx --dry-run --with-diff' komandoa exekutatuz.",
"certmanager_domain_dns_ip_differs_from_public_ip": "'{domain}' domeinurako DNS balioak ez datoz bat zerbitzariaren IParekin. Egiaztatu 'DNS balioak' (oinarrizkoa) kategoria diagnostikoen atalean. 'A' balioak duela gutxi aldatu badituzu, itxaron hedatu daitezen (badaude DNSen hedapena ikusteko erramintak interneten). (Zertan ari zeren baldin badakizu, erabili '--no-checks' egiaztapen horiek desgaitzeko.)",
@@ -49,7 +49,6 @@
"config_validate_email": "Benetazko posta elektronikoa izan behar da",
"config_validate_time": "OO:MM formatua duen ordu bat izan behar da",
"config_validate_url": "Benetazko URL bat izan behar da",
- "config_version_not_supported": "Ezinezkoa da konfigurazio-panelaren '{version}' bertsioa erabiltzea.",
"app_restore_script_failed": "Errorea gertatu da aplikazioa lehengoratzeko aginduan",
"app_upgrade_some_app_failed": "Ezinezkoa izan da aplikazio batzuk eguneratzea",
"app_install_failed": "Ezinezkoa izan da {app} instalatzea: {error}",
@@ -200,7 +199,7 @@
"backup_archive_writing_error": "Ezinezkoa izan da '{source}' ('{dest}' fitxategiak eskatu dituenak) fitxategia '{archive}' konprimatutako babeskopian sartzea",
"backup_ask_for_copying_if_needed": "Behin-behinean {size}MB erabili nahi dituzu babeskopia gauzatu ahal izateko? (Horrela egiten da fitxategi batzuk ezin direlako modu eraginkorragoan prestatu.)",
"backup_cant_mount_uncompress_archive": "Ezinezkoa izan da deskonprimatutako fitxategia muntatzea idazketa-babesa duelako",
- "backup_created": "Babeskopia sortu da",
+ "backup_created": "Babeskopia sortu da: {name}",
"backup_copying_to_organize_the_archive": "{size}MB kopiatzen fitxategia antolatzeko",
"backup_couldnt_bind": "Ezin izan da {src} {dest}-ra lotu.",
"backup_output_directory_forbidden": "Aukeratu beste katalogo bat emaitza gordetzeko. Babeskopiak ezin dira sortu /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var edo /home/yunohost.backup/archives azpi-katalogoetan",
@@ -337,7 +336,6 @@
"domain_dns_registrar_supported": "YunoHostek automatikoki antzeman du domeinu hau **{registrar}** erregistro-enpresak kudeatzen duela. Nahi baduzu YunoHostek automatikoki konfiguratu ditzake DNS ezarpenak, API egiaztagiri zuzenak zehazten badituzu. API egiaztagiriak non lortzeko dokumentazioa orri honetan duzu: https://yunohost.org/registar_api_{registrar}. (Baduzu DNS erregistroak eskuz konfiguratzeko aukera ere, gidalerro hauetan ageri den bezala: https://yunohost.org/dns)",
"domain_dns_push_failed_to_list": "Ezinezkoa izan da APIa erabiliz oraingo erregistroak antzematea: {error}",
"domain_dns_push_already_up_to_date": "Ezarpenak egunean daude, ez dago zereginik.",
- "domain_config_features_disclaimer": "Oraingoz, posta elektronikoa edo XMPP funtzioak gaitu/desgaitzeak DNS ezarpenei soilik eragiten die, ez sistemaren konfigurazioari!",
"domain_config_mail_out": "Bidalitako mezuak",
"domain_config_xmpp": "Bat-bateko mezularitza (XMPP)",
"good_practices_about_user_password": "Erabiltzaile-pasahitz berria ezartzear zaude. Pasahitzak 8 karaktere izan beharko lituzke gutxienez, baina gomendagarria da pasahitz luzeagoa erabiltzea (esaldi bat, esaterako) edota karaktere desberdinak erabiltzea (hizki larriak, txikiak, zenbakiak eta karaktere bereziak).",
@@ -400,7 +398,6 @@
"hook_exec_not_terminated": "Aginduak ez du behar bezala amaitu: {path}",
"log_corrupted_md_file": "Erregistroei lotutako YAML metadatu fitxategia kaltetuta dago: '{md_file}\nErrorea: {error}'",
"log_letsencrypt_cert_renew": "Berriztu '{}' Let's Encrypt ziurtagiria",
- "log_remove_on_failed_restore": "Ezabatu '{}' babeskopia baten lehengoratzeak huts egin eta gero",
"diagnosis_package_installed_from_sury_details": "Sury izena duen kanpoko biltegi batetik instalatu dira pakete batzuk, nahi gabe. YunoHosten taldeak hobekuntzak egin ditu pakete hauek kudeatzeko, baina litekeena da PHP7.3 aplikazioak Stretch sistema eragilean instalatu zituzten kasu batzuetan arazoak sortzea. Egoera hau konpontzeko, honako komando hau exekutatu beharko zenuke: {wrong_ehlo}
{right_ehlo}
{file}
foguèt modificat manualament.",
"diagnosis_regenconf_manually_modified_details": "Es probablament bon tan que sabètz çò que fasètz ;) !",
"diagnosis_security_vulnerable_to_meltdown": "Semblatz èsser vulnerable a la vulnerabilitat de seguretat critica de Meltdown",
"diagnosis_description_basesystem": "Sistèma de basa",
diff --git a/locales/pl.json b/locales/pl.json
index 6734e6558..c58f7223e 100644
--- a/locales/pl.json
+++ b/locales/pl.json
@@ -1,9 +1,183 @@
{
"password_too_simple_1": "Hasło musi mieć co najmniej 8 znaków",
"app_already_up_to_date": "{app} jest obecnie aktualna",
- "app_already_installed": "{app} jest już zainstalowane",
+ "app_already_installed": "{app} jest już zainstalowana",
"already_up_to_date": "Nic do zrobienia. Wszystko jest obecnie aktualne.",
"admin_password": "Hasło administratora",
"action_invalid": "Nieprawidłowe działanie '{action:s}'",
- "aborting": "Przerywanie."
+ "aborting": "Przerywanie.",
+ "domain_config_auth_consumer_key": "Klucz konsumenta",
+ "domain_config_cert_validity": "Ważność",
+ "visitors": "Odwiedzający",
+ "app_start_install": "Instalowanie {app}...",
+ "app_unknown": "Nieznana aplikacja",
+ "ask_main_domain": "Domena główna",
+ "backup_created": "Utworzono kopię zapasową: {name}",
+ "firewall_reloaded": "Przeładowano zaporę sieciową",
+ "user_created": "Utworzono użytkownika",
+ "yunohost_installing": "Instalowanie YunoHost...",
+ "global_settings_setting_smtp_allow_ipv6": "Zezwól na IPv6",
+ "user_deleted": "Usunięto użytkownika",
+ "domain_config_default_app": "Domyślna aplikacja",
+ "restore_complete": "Przywracanie zakończone",
+ "domain_deleted": "Usunięto domenę",
+ "domains_available": "Dostępne domeny:",
+ "domain_config_api_protocol": "API protokołu",
+ "domain_config_auth_application_key": "Klucz aplikacji",
+ "diagnosis_description_systemresources": "Zasoby systemu",
+ "log_user_import": "Importuj użytkowników",
+ "system_upgraded": "Zaktualizowano system",
+ "diagnosis_description_regenconf": "Konfiguracja systemu",
+ "diagnosis_description_apps": "Aplikacje",
+ "diagnosis_description_basesystem": "Podstawowy system",
+ "unlimit": "Brak limitu",
+ "global_settings_setting_pop3_enabled": "Włącz POP3",
+ "domain_created": "Utworzono domenę",
+ "ask_new_admin_password": "Nowe hasło administracyjne",
+ "ask_new_domain": "Nowa domena",
+ "ask_new_path": "Nowa ścieżka",
+ "downloading": "Pobieranie...",
+ "ask_password": "Hasło",
+ "backup_deleted": "Usunięto kopię zapasową: {name}.",
+ "done": "Gotowe",
+ "diagnosis_description_dnsrecords": "Rekordy DNS",
+ "diagnosis_description_ip": "Połączenie z internetem",
+ "diagnosis_description_mail": "Email",
+ "diagnosis_mail_ehlo_could_not_diagnose_details": "Błąd: {error}",
+ "diagnosis_mail_queue_unavailable_details": "Błąd: {error}",
+ "diagnosis_http_could_not_diagnose_details": "Błąd: {error}",
+ "installation_complete": "Instalacja zakończona",
+ "app_start_remove": "Usuwanie {app}...",
+ "app_start_restore": "Przywracanie {app}...",
+ "app_upgraded": "Zaktualizowano {app}",
+ "extracting": "Rozpakowywanie...",
+ "app_removed": "Odinstalowano {app}",
+ "upgrade_complete": "Aktualizacja zakończona",
+ "global_settings_setting_backup_compress_tar_archives": "Kompresuj kopie zapasowe",
+ "global_settings_setting_nginx_compatibility": "Kompatybilność z NGINX",
+ "global_settings_setting_nginx_redirect_to_https": "Wymuszaj HTTPS",
+ "ask_admin_username": "Nazwa użytkownika administratora",
+ "ask_fullname": "Pełne imię i nazwisko",
+ "upgrading_packages": "Aktualizowanie paczek...",
+ "admins": "Administratorzy",
+ "diagnosis_ports_could_not_diagnose_details": "Błąd: {error}",
+ "log_settings_set": "Zastosuj ustawienia",
+ "domain_config_cert_issuer": "Organ certyfikacji",
+ "domain_config_cert_summary": "Status certyfikatu",
+ "global_settings_setting_ssh_compatibility": "Kompatybilność z SSH",
+ "global_settings_setting_ssh_port": "Port SSH",
+ "log_settings_reset": "Resetuj ustawienia",
+ "log_tools_migrations_migrate_forward": "Uruchom migracje",
+ "app_action_cannot_be_ran_because_required_services_down": "Następujące usługi powinny być uruchomione, aby rozpocząć to działanie: {services}. Spróbuj uruchomić je ponownie aby kontynuować (i dowiedzieć się, dlaczego były one wyłączone)",
+ "app_argument_choice_invalid": "Wybierz poprawną wartość dla argumentu '{name}': '{value}' nie znajduje się w liście poprawnych opcji ({choices})",
+ "app_action_broke_system": "Wydaje się, że ta akcja przerwała te ważne usługi: {services}",
+ "additional_urls_already_removed": "Dodatkowy URL '{url}' już usunięty w dodatkowym URL dla uprawnienia '{permission}'",
+ "additional_urls_already_added": "Dodatkowy URL '{url}' już dodany w dodatkowym URL dla uprawnienia '{permission}'",
+ "app_arch_not_supported": "Ta aplikacja może być zainstalowana tylko na architekturach {required}, a twoja architektura serwera to {current}",
+ "app_argument_invalid": "Wybierz poprawną wartość dla argumentu '{name}': {error}",
+ "all_users": "Wszyscy użytkownicy YunoHost",
+ "app_action_failed": "Nie udało się uruchomić akcji {action} dla aplikacji {app}",
+ "app_already_installed_cant_change_url": "Ta aplikacja jest już zainstalowana. URL nie może zostać zmieniony przy użyciu tej funkcji. Sprawdź czy można zmienić w `app changeurl`",
+ "app_id_invalid": "Nieprawidłowy identyfikator aplikacji(ID)",
+ "app_change_url_require_full_domain": "Nie można przenieść aplikacji {app} na nowy adres URL, ponieważ wymaga ona pełnej domeny (tj. ze ścieżką = /)",
+ "app_install_files_invalid": "Tych plików nie można zainstalować",
+ "app_make_default_location_already_used": "Nie można ustawić '{app}' jako domyślnej aplikacji w domenie '{domain}' ponieważ jest już używana przez '{other_app}'",
+ "app_change_url_identical_domains": "Stara i nowa domena/ścieżka_url są identyczne („{domain}{path}”), nic nie trzeba robić.",
+ "app_config_unable_to_read": "Nie udało się odczytać wartości panelu konfiguracji.",
+ "app_config_unable_to_apply": "Nie udało się zastosować wartości panelu konfiguracji.",
+ "app_install_failed": "Nie udało się zainstalować {app}: {error}",
+ "apps_catalog_failed_to_download": "Nie można pobrać katalogu aplikacji app catalog: {error}",
+ "app_argument_required": "Argument „{name}” jest wymagany",
+ "app_not_properly_removed": "Aplikacja {app} nie została poprawnie usunięta",
+ "app_upgrade_failed": "Nie można uaktualnić {app}: {error}",
+ "backup_abstract_method": "Ta metoda tworzenia kopii zapasowych nie została jeszcze zaimplementowana",
+ "backup_actually_backuping": "Tworzenie archiwum kopii zapasowej z zebranych plików...",
+ "backup_applying_method_copy": "Kopiowanie wszystkich plików do kopii zapasowej...",
+ "backup_applying_method_tar": "Tworzenie kopii zapasowej archiwum TAR..",
+ "backup_archive_app_not_found": "Nie można znaleźć aplikacji {app} w archiwum kopii zapasowych",
+ "backup_archive_broken_link": "Nie można uzyskać dostępu do archiwum kopii zapasowych (broken link to {path})",
+ "backup_csv_addition_failed": "Nie udało się dodać plików do kopii zapasowej do pliku CSV.",
+ "backup_creation_failed": "Nie udało się utworzyć archiwum kopii zapasowej",
+ "backup_csv_creation_failed": "Nie udało się utworzyć wymaganego pliku CSV do przywracania.",
+ "backup_custom_mount_error": "Niestandardowa metoda tworzenia kopii zapasowej nie mogła przejść etapu „mount”",
+ "backup_applying_method_custom": "Wywołuję niestandardową metodę tworzenia kopii zapasowych '{method}'...",
+ "app_remove_after_failed_install": "Usuwanie aplikacji po niepowodzeniu instalacji...",
+ "app_upgrade_script_failed": "Wystąpił błąd w skrypcie aktualizacji aplikacji",
+ "apps_catalog_init_success": "Zainicjowano system katalogu aplikacji!",
+ "apps_catalog_obsolete_cache": "Pamięć podręczna katalogu aplikacji jest pusta lub przestarzała.",
+ "app_extraction_failed": "Nie można wyodrębnić plików instalacyjnych",
+ "app_packaging_format_not_supported": "Ta aplikacja nie może zostać zainstalowana, ponieważ jej format opakowania nie jest obsługiwany przez twoją wersję YunoHost. Prawdopodobnie powinieneś rozważyć aktualizację swojego systemu.",
+ "app_manifest_install_ask_domain": "Wybierz domenę, w której ta aplikacja ma zostać zainstalowana",
+ "app_manifest_install_ask_admin": "Wybierz użytkownika administratora dla tej aplikacji",
+ "app_manifest_install_ask_password": "Wybierz hasło administratora dla tej aplikacji",
+ "app_manifest_install_ask_is_public": "Czy ta aplikacja powinna być udostępniana anonimowym użytkownikom?",
+ "ask_user_domain": "Domena używana dla adresu e-mail użytkownika i konta XMPP",
+ "app_upgrade_app_name": "Aktualizuję {app}...",
+ "app_install_script_failed": "Wystąpił błąd w skrypcie instalacyjnym aplikacji",
+ "apps_catalog_update_success": "Katalog aplikacji został zaktualizowany!",
+ "apps_catalog_updating": "Aktualizowanie katalogu aplikacji...",
+ "app_label_deprecated": "To polecenie jest przestarzałe! Użyj nowego polecenia „yunohost user permission update”, aby zarządzać etykietą aplikacji.",
+ "app_change_url_no_script": "Aplikacja „{app_name}” nie obsługuje jeszcze modyfikacji adresów URL. Możesz spróbować ją zaaktualizować.",
+ "app_change_url_success": "Adres URL aplikacji {app} to teraz {domain}{path}",
+ "app_not_upgraded": "Nie udało się zaktualizować aplikacji „{failed_app}”, w związku z czym anulowano aktualizacje następujących aplikacji: {apps}",
+ "app_upgrade_several_apps": "Następujące aplikacje zostaną uaktualnione: {apps}",
+ "app_not_correctly_installed": "Wygląda na to, że aplikacja {app} jest nieprawidłowo zainstalowana",
+ "app_not_installed": "Nie można znaleźć aplikacji {app} na liście zainstalowanych aplikacji: {all_apps}",
+ "app_requirements_checking": "Sprawdzam wymagania dla aplikacji {app}...",
+ "app_upgrade_some_app_failed": "Niektórych aplikacji nie udało się zaktualizować",
+ "backup_app_failed": "Nie udało się utworzyć kopii zapasowej {app}",
+ "backup_archive_name_exists": "Archiwum kopii zapasowych o tej nazwie już istnieje.",
+ "backup_archive_open_failed": "Nie można otworzyć archiwum kopii zapasowej",
+ "backup_archive_writing_error": "Nie udało się dodać plików '{source}' (nazwanych w archiwum '{dest}') do utworzenia kopii zapasowej skompresowanego archiwum '{archive}'",
+ "backup_ask_for_copying_if_needed": "Czy chcesz wykonać kopię zapasową tymczasowo używając {size} MB? (Ta metoda jest stosowana, ponieważ niektóre pliki nie mogły zostać przygotowane przy użyciu bardziej wydajnej metody.)",
+ "backup_cant_mount_uncompress_archive": "Nie można zamontować nieskompresowanego archiwum jako chronione przed zapisem",
+ "backup_copying_to_organize_the_archive": "Kopiowanie {size} MB w celu zorganizowania archiwum",
+ "backup_couldnt_bind": "Nie udało się powiązać {src} z {dest}.",
+ "backup_archive_corrupted": "Wygląda na to, że archiwum kopii zapasowej '{archive}' jest uszkodzone: {error}",
+ "backup_cleaning_failed": "Nie udało się wyczyścić folderu tymczasowej kopii zapasowej",
+ "backup_create_size_estimation": "Archiwum będzie zawierać około {size} danych.",
+ "app_location_unavailable": "Ten adres URL jest niedostępny lub powoduje konflikt z już zainstalowanymi aplikacja(mi):\n{apps}",
+ "app_restore_failed": "Nie można przywrócić {app}: {error}",
+ "app_restore_script_failed": "Wystąpił błąd w skrypcie przywracania aplikacji",
+ "app_full_domain_unavailable": "Przepraszamy, ta aplikacja musi być zainstalowana we własnej domenie, ale inna aplikacja jest już zainstalowana w tej domenie „{domain}”. Zamiast tego możesz użyć subdomeny dedykowanej tej aplikacji.",
+ "app_resource_failed": "Nie udało się zapewnić, anulować obsługi administracyjnej lub zaktualizować zasobów aplikacji {app}: {error}",
+ "app_manifest_install_ask_path": "Wybierz ścieżkę adresu URL (po domenie), w której ta aplikacja ma zostać zainstalowana",
+ "app_not_enough_disk": "Ta aplikacja wymaga {required} wolnego miejsca.",
+ "app_not_enough_ram": "Ta aplikacja wymaga {required} pamięci RAM do zainstalowania/uaktualnienia, ale obecnie dostępna jest tylko {current}.",
+ "app_start_backup": "Zbieram pliki do utworzenia kopii zapasowej dla {app}...",
+ "app_yunohost_version_not_supported": "Ta aplikacja wymaga YunoHost >= {required}, ale aktualnie zainstalowana wersja to {current}",
+ "apps_already_up_to_date": "Wszystkie aplikacje są już aktualne",
+ "backup_archive_system_part_not_available": "Część systemowa '{part}' jest niedostępna w tej kopii zapasowej",
+ "backup_custom_backup_error": "Niestandardowa metoda tworzenia kopii zapasowej nie mogła przejść kroku 'backup'.",
+ "app_argument_password_no_default": "Błąd podczas analizowania argumentu hasła „{name}”: argument hasła nie może mieć wartości domyślnej ze względów bezpieczeństwa",
+ "app_sources_fetch_failed": "Nie można pobrać plików źródłowych, czy adres URL jest poprawny?",
+ "app_manifest_install_ask_init_admin_permission": "Kto powinien mieć dostęp do funkcji administracyjnych tej aplikacji? (Można to później zmienić)",
+ "app_manifest_install_ask_init_main_permission": "Kto powinien mieć dostęp do tej aplikacji? (Można to później zmienić)",
+ "ask_admin_fullname": "Pełne imię i nazwisko administratora",
+ "app_change_url_failed": "Nie udało się zmienić adresu URL aplikacji {app}: {error}",
+ "app_change_url_script_failed": "Wystąpił błąd w skrypcie zmiany adresu URL",
+ "app_failed_to_upgrade_but_continue": "Nie udało zaktualizować się aplikacji {failed_app}, przechodzenie do następnych aktualizacji według żądania. Uruchom komendę 'yunohost log show {operation_logger_name}', aby sprawdzić logi dotyczące błędów",
+ "certmanager_cert_signing_failed": "Nie udało się zarejestrować nowego certyfikatu",
+ "certmanager_cert_renew_success": "Pomyślne odnowienie certyfikatu Let's Encrypt dla domeny '{domain}'",
+ "backup_delete_error": "Nie udało się usunąć '{path}'",
+ "certmanager_attempt_to_renew_nonLE_cert": "Certyfikat dla domeny '{domain}' nie został wystawiony przez Let's Encrypt. Automatyczne odnowienie jest niemożliwe!",
+ "backup_archive_cant_retrieve_info_json": "Nieudane wczytanie informacji dla archiwum '{archive}'... Plik info.json nie może zostać odzyskany (lub jest niepoprawny).",
+ "backup_method_custom_finished": "Tworzenie kopii zapasowej według własnej metody '{method}' zakończone",
+ "backup_nothings_done": "Brak danych do zapisania",
+ "app_unsupported_remote_type": "Niewspierany typ zdalny użyty w aplikacji",
+ "backup_archive_name_unknown": "Nieznane, lokalne archiwum kopii zapasowej o nazwie '{name}'",
+ "backup_output_directory_not_empty": "Należy wybrać pusty katalog dla danych wyjściowych",
+ "certmanager_attempt_to_renew_valid_cert": "Certyfikat dla domeny '{domain}' nie jest bliski wygaśnięciu! (Możesz użyć komendy z dopiskiem --force jeśli wiesz co robisz)",
+ "certmanager_cert_install_success": "Pomyślna instalacja certyfikatu Let's Encrypt dla domeny '{domain}'",
+ "certmanager_attempt_to_replace_valid_cert": "Właśnie zamierzasz nadpisać dobry i poprawny certyfikat dla domeny '{domain}'! (Użyj komendy z dopiskiem --force, aby ominąć)",
+ "backup_method_copy_finished": "Zakończono tworzenie kopii zapasowej",
+ "certmanager_certificate_fetching_or_enabling_failed": "Próba użycia nowego certyfikatu dla {domain} zakończyła się niepowodzeniem...",
+ "backup_method_tar_finished": "Utworzono archiwum kopii zapasowej TAR",
+ "backup_mount_archive_for_restore": "Przygotowywanie archiwum do przywrócenia...",
+ "certmanager_cert_install_failed": "Nieudana instalacja certyfikatu Let's Encrypt dla {domains}",
+ "certmanager_cert_install_failed_selfsigned": "Nieudana instalacja certyfikatu self-signed dla {domains}",
+ "certmanager_cert_install_success_selfsigned": "Pomyślna instalacja certyfikatu self-signed dla domeny '{domain}'",
+ "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"
}
\ No newline at end of file
diff --git a/locales/pt.json b/locales/pt.json
index a7b574949..0aa6b8223 100644
--- a/locales/pt.json
+++ b/locales/pt.json
@@ -204,7 +204,6 @@
"config_cant_set_value_on_section": "Você não pode setar um único valor na seção de configuração inteira.",
"config_validate_time": "Deve ser um horário válido como HH:MM",
"config_validate_url": "Deve ser uma URL válida",
- "config_version_not_supported": "Versões do painel de configuração '{version}' não são suportadas.",
"danger": "Perigo:",
"diagnosis_basesystem_ynh_inconsistent_versions": "Você está executando versões inconsistentes dos pacotes YunoHost... provavelmente por causa de uma atualização parcial ou que falhou.",
"diagnosis_description_basesystem": "Sistema base",
@@ -247,4 +246,4 @@
"certmanager_cert_renew_success": "Certificado Let's Encrypt renovado para o domínio '{domain}'",
"certmanager_warning_subdomain_dns_record": "O subdomínio '{subdomain}' não resolve para o mesmo IP que '{domain}'. Algumas funcionalidades não estarão disponíveis até que você conserte isto e regenere o certificado.",
"admins": "Admins"
-}
+}
\ No newline at end of file
diff --git a/locales/pt_BR.json b/locales/pt_BR.json
index 0967ef424..9e26dfeeb 100644
--- a/locales/pt_BR.json
+++ b/locales/pt_BR.json
@@ -1 +1 @@
-{}
+{}
\ No newline at end of file
diff --git a/locales/ru.json b/locales/ru.json
index 40e7629e3..2c4e703da 100644
--- a/locales/ru.json
+++ b/locales/ru.json
@@ -106,7 +106,6 @@
"certmanager_domain_dns_ip_differs_from_public_ip": "DNS-записи для домена '{domain}' отличаются от IP этого сервера. Пожалуйста, проверьте категорию 'DNS-записи' (основные) в диагностике для получения дополнительной информации. Если вы недавно изменили свою A-запись, пожалуйста, подождите, пока она распространится (некоторые программы проверки распространения DNS доступны в интернете). (Если вы знаете, что делаете, используйте '--no-checks', чтобы отключить эти проверки.)",
"certmanager_domain_not_diagnosed_yet": "Для домена {domain} еще нет результатов диагностики. Пожалуйста, перезапустите диагностику для категорий 'DNS-записи' и 'Домены', чтобы проверить, готов ли домен к Let's Encrypt. (Или, если вы знаете, что делаете, используйте '--no-checks', чтобы отключить эти проверки.)",
"config_validate_url": "Должна быть правильная ссылка",
- "config_version_not_supported": "Версии конфигурационной панели '{version}' не поддерживаются.",
"confirm_app_install_danger": "ОПАСНО! Это приложение все еще является экспериментальным (если не сказать, что оно явно не работает)! Вам НЕ следует устанавливать его, если вы НЕ знаете, что делаете. Если это приложение не будет работать или сломает вашу систему, мы НЕ будем оказывать техническую поддержку... Если вы все равно готовы рискнуть, введите '{answers}'",
"confirm_app_install_thirdparty": "ВАЖНО! Это приложение не входит в каталог приложений YunoHost. Установка сторонних приложений может нарушить целостность и безопасность вашей системы. Вам НЕ следует устанавливать его, если вы НЕ знаете, что делаете. Если это приложение не будет работать или сломает вашу систему, мы НЕ будем оказывать техническую поддержку... Если вы все равно готовы рискнуть, введите '{answers}'",
"config_apply_failed": "Не удалось применить новую конфигурацию: {error}",
diff --git a/locales/sk.json b/locales/sk.json
index 25bd82988..359b2e562 100644
--- a/locales/sk.json
+++ b/locales/sk.json
@@ -144,7 +144,6 @@
"config_validate_email": "Toto by mal byť platný e-mail",
"config_validate_time": "Toto by mal byť platný čas vo formáte HH:MM",
"config_validate_url": "Toto by mala byť platná URL adresa webu",
- "config_version_not_supported": "Verzie konfiguračného panela '{version}' nie sú podporované.",
"danger": "Nebezpečenstvo:",
"confirm_app_install_danger": "NEBEZPEČENSTVO! Táto aplikácia je experimentálna (ak vôbec funguje)! Pravdepodobne by ste ju NEMALI inštalovať, pokiaľ si nie ste istý, čo robíte. NEPOSKYTNEME VÁM ŽIADNU POMOC, ak táto aplikácia nebude fungovať alebo rozbije Váš systém… Ak sa rozhodnete i napriek tomu podstúpiť toto riziko, zadajte '{answers}'",
"confirm_app_install_thirdparty": "NEBEZPEČENSTVO! Táto aplikácia nie je súčasťou katalógu aplikácií YunoHost. Inštalovaním aplikácií tretích strán môžete ohroziť integritu a bezpečnosť Vášho systému. Pravdepodobne by ste NEMALI pokračovať v inštalácií, pokiaľ neviete, čo robíte. NEPOSKYTNEME VÁM ŽIADNU POMOC, ak táto aplikácia nebude fungovať alebo rozbije Váš systém… Ak sa rozhodnete i napriek tomu podstúpiť toto riziko, zadajte '{answers}'",
@@ -250,4 +249,4 @@
"all_users": "Všetci používatelia YunoHost",
"app_manifest_install_ask_init_main_permission": "Kto má mať prístup k tejto aplikácii? (Nastavenie môžete neskôr zmeniť)",
"certmanager_cert_install_failed": "Inštalácia Let's Encrypt certifikátu pre {domains} skončila s chybou"
-}
+}
\ No newline at end of file
diff --git a/locales/tr.json b/locales/tr.json
index 3ba829b95..1af0ffd54 100644
--- a/locales/tr.json
+++ b/locales/tr.json
@@ -5,5 +5,15 @@
"already_up_to_date": "Yapılacak yeni bir şey yok. Her şey zaten güncel.",
"app_action_broke_system": "Bu işlem bazı hizmetleri bozmuş olabilir: {services}",
"good_practices_about_user_password": "Şimdi yeni bir kullanıcı şifresi tanımlamak üzeresiniz. Parola en az 8 karakter uzunluğunda olmalıdır - ancak daha uzun bir parola (yani bir parola) ve/veya çeşitli karakterler (büyük harf, küçük harf, rakamlar ve özel karakterler) daha iyidir.",
- "aborting": "İptal ediliyor."
+ "aborting": "İptal ediliyor.",
+ "app_action_failed": "{app} uygulaması için {action} eylemini çalıştırma başarısız",
+ "admins": "Yöneticiler",
+ "all_users": "Tüm YunoHost kullanıcıları",
+ "app_already_up_to_date": "{app} zaten güncel",
+ "app_already_installed": "{app} zaten kurulu",
+ "app_already_installed_cant_change_url": "Bu uygulama zaten kurulu. URL yalnızca bu işlev kullanarak değiştirilemez. Eğer varsa `app changeurl`'i kontrol edin.",
+ "additional_urls_already_added": "Ek URL '{url}' zaten '{permission}' izni için ek URL'ye eklendi",
+ "additional_urls_already_removed": "Ek URL '{url}', '{permission}' izni için ek URL'de zaten kaldırıldı",
+ "app_action_cannot_be_ran_because_required_services_down": "Bu eylemi gerçekleştirmek için şu servisler çalışıyor olmalıdır: {services}. Devam etmek için onları yeniden başlatın (ve muhtemelen neden çalışmadığını araştırın).",
+ "app_arch_not_supported": "Bu uygulama yalnızca {required} işlemci mimarisi üzerine kurulabilir ancak sunucunuzun işlemci mimarisi {current}."
}
\ No newline at end of file
diff --git a/locales/uk.json b/locales/uk.json
index 281f2dba7..fca0ea360 100644
--- a/locales/uk.json
+++ b/locales/uk.json
@@ -191,7 +191,6 @@
"log_domain_remove": "Вилучення домену '{}' з конфігурації системи",
"log_domain_add": "Додавання домену '{}' в конфігурацію системи",
"log_remove_on_failed_install": "Вилучення '{}' після невдалого встановлення",
- "log_remove_on_failed_restore": "Вилучення '{}' після невдалого відновлення з резервного архіву",
"log_backup_restore_app": "Відновлення '{}' з архіву резервних копій",
"log_backup_restore_system": "Відновлення системи з резервного архіву",
"log_backup_create": "Створення резервного архіву",
@@ -235,7 +234,7 @@
"group_already_exist_on_system": "Група {group} вже існує в групах системи",
"group_already_exist": "Група {group} вже існує",
"good_practices_about_user_password": "Зараз ви збираєтеся поставити новий пароль користувача. Пароль повинен складатися не менше ніж з 8 символів, але хорошою практикою є використання більш довгого пароля (тобто гасла) і/або використання різних символів (великих, малих, цифр і спеціальних символів).",
- "good_practices_about_admin_password": "Зараз ви збираєтеся поставити новий пароль адмініструванні. Пароль повинен складатися не менше ніж з 8 символів, але хорошою практикою є використання більш довгого пароля (тобто парольного гасла) і/або використання різних символів (великих, малих, цифр і спеціальних символів).",
+ "good_practices_about_admin_password": "Зараз ви збираєтеся поставити новий пароль адміністрування. Пароль повинен складатися не менше ніж з 8 символів, але хорошою практикою є використання більш довгого пароля (тобто парольного гасла) і/або використання різних символів (великих, малих, цифр і спеціальних символів).",
"global_settings_setting_smtp_relay_password": "Пароль SMTP-ретрансляції",
"global_settings_setting_smtp_relay_user": "Користувач SMTP-ретрансляції",
"global_settings_setting_smtp_relay_port": "Порт SMTP-ретрансляції",
@@ -279,7 +278,7 @@
"domain_cannot_remove_main": "Ви не можете вилучити '{domain}', бо це основний домен, спочатку вам потрібно встановити інший домен в якості основного за допомогою 'yunohost domain main-domain -n {file}
似乎已被手动修改。",
"diagnosis_regenconf_allgood": "所有配置文件均符合建议的配置!",
"diagnosis_mail_queue_too_big": "邮件队列中的待处理电子邮件过多({nb_pending} emails)",
@@ -420,35 +420,35 @@
"diagnosis_ip_connected_ipv4": "服务器通过IPv4连接到Internet!",
"diagnosis_no_cache": "尚无类别 '{category}'的诊断缓存",
"diagnosis_failed": "无法获取类别 '{category}'的诊断结果: {error}",
- "diagnosis_package_installed_from_sury_details": "一些软件包被无意中从一个名为Sury的第三方仓库安装。YunoHost团队改进了处理这些软件包的策略,但预计一些安装了PHP7.3应用程序的设置在仍然使用Stretch的情况下还有一些不一致的地方。为了解决这种情况,你应该尝试运行以下命令:{wrong_ehlo}
{right_ehlo}
{wrong_ehlo}
{right_ehlo}
{mountpoint}
(在设备{device}
上)仍有 {free} ({free_percent}%) 空间(在{total}中)!",
"diagnosis_diskusage_low": "存储器{mountpoint}
(在设备{device}
上)只有{free} ({free_percent}%) 的空间。({free_percent}%)的剩余空间(在{total}中)。要小心。",
"diagnosis_diskusage_verylow": "存储器{mountpoint}
(在设备{device}
上)仅剩余{free} ({free_percent}%) (剩余{total})个空间。您应该真正考虑清理一些空间!",
- "diagnosis_services_bad_status_tip": "你可以尝试重新启动服务,如果没有效果,可以看看webadmin中的服务日志(从命令行,你可以用{type}
{name}
{current}期望值: {value}
",
@@ -467,8 +467,8 @@
"log_help_to_get_log": "要查看操作'{desc}'的日志,请使用命令'yunohost log show {name}'",
"log_link_to_log": "此操作的完整日志: '{desc}'",
"log_corrupted_md_file": "与日志关联的YAML元数据文件已损坏: '{md_file}\n错误: {error}'",
- "iptables_unavailable": "你不能在这里使用iptables。你要么在一个容器中,要么你的内核不支持它",
- "ip6tables_unavailable": "你不能在这里使用ip6tables。你要么在一个容器中,要么你的内核不支持它",
+ "iptables_unavailable": "您不能在这里使用iptables。您要么在一个容器中,要么您的内核不支持它",
+ "ip6tables_unavailable": "您不能在这里使用ip6tables。您要么在一个容器中,要么您的内核不支持它",
"log_regen_conf": "重新生成系统配置'{}'",
"log_letsencrypt_cert_renew": "续订'{}'的“Let's Encrypt”证书",
"log_selfsigned_cert_install": "在 '{}'域上安装自签名证书",
@@ -481,10 +481,9 @@
"log_domain_remove": "从系统配置中删除 '{}' 域",
"log_domain_add": "将 '{}'域添加到系统配置中",
"log_remove_on_failed_install": "安装失败后删除 '{}'",
- "log_remove_on_failed_restore": "从备份存档还原失败后,删除 '{}'",
"log_backup_restore_app": "从备份存档还原 '{}'",
"log_backup_restore_system": "从备份档案还原系统",
- "permission_currently_allowed_for_all_users": "这个权限目前除了授予其他组以外,还授予所有用户。你可能想删除'all_users'权限或删除目前授予它的其他组。",
+ "permission_currently_allowed_for_all_users": "这个权限目前除了授予其他组以外,还授予所有用户。您可能想删除'all_users'权限或删除目前授予它的其他组。",
"permission_creation_failed": "无法创建权限'{permission}': {error}",
"permission_created": "权限'{permission}'已创建",
"permission_cannot_remove_main": "不允许删除主要权限",
@@ -529,7 +528,7 @@
"migration_ldap_rollback_success": "系统回滚。",
"migration_ldap_migration_failed_trying_to_rollback": "无法迁移...试图回滚系统。",
"migration_ldap_can_not_backup_before_migration": "迁移失败之前,无法完成系统的备份。错误: {error}",
- "migration_ldap_backup_before_migration": "在实际迁移之前,请创建LDAP数据库和应用程序设置的备份。",
+ "migration_ldap_backup_before_migration": "在实际迁移之前,请创建LDAP数据库和应用设置的备份。",
"main_domain_changed": "主域已更改",
"main_domain_change_failed": "无法更改主域",
"mail_unavailable": "该电子邮件地址是保留的,并且将自动分配给第一个用户",
@@ -541,7 +540,7 @@
"log_tools_reboot": "重新启动服务器",
"log_tools_shutdown": "关闭服务器",
"log_tools_upgrade": "升级系统软件包",
- "log_tools_postinstall": "安装好你的YunoHost服务器后",
+ "log_tools_postinstall": "安装好您的YunoHost服务器后",
"log_tools_migrations_migrate_forward": "运行迁移",
"log_domain_main_domain": "将 '{}' 设为主要域",
"log_user_permission_reset": "重置权限'{}'",
@@ -554,16 +553,16 @@
"log_user_create": "添加用户'{}'",
"domain_registrar_is_not_configured": "尚未为域 {domain} 配置注册商。",
"domain_dns_push_not_applicable": "的自动DNS配置的特征是不适用域{domain}。您应该按照 https://yunohost.org/dns_config 上的文档手动配置DNS 记录。",
- "disk_space_not_sufficient_update": "没有足够的磁盘空间来更新此应用程序",
+ "disk_space_not_sufficient_update": "没有足够的磁盘空间来更新此应用",
"diagnosis_high_number_auth_failures": "最近出现了大量可疑的失败身份验证。您的fail2ban正在运行且配置正确,或使用自定义端口的SSH作为https://yunohost.org/解释的安全性。",
- "diagnosis_apps_not_in_app_catalog": "此应用程序不在 YunoHost 的应用程序目录中。如果它过去有被删除过,您应该考虑卸载此应用程,因为它不会更新,并且可能会损害您系统的完整和安全性。",
+ "diagnosis_apps_not_in_app_catalog": "此应用不在 YunoHost 的应用目录中。如果它过去有被删除过,您应该考虑卸载此应用程,因为它不会更新,并且可能会损害您系统的完整和安全性。",
"app_config_unable_to_apply": "无法应用配置面板值。",
"app_config_unable_to_read": "无法读取配置面板值。",
"config_forbidden_keyword": "关键字“{keyword}”是保留的,您不能创建或使用带有此 ID 的问题的配置面板。",
"config_no_panel": "未找到配置面板。",
"config_unknown_filter_key": "该过滤器钥匙“{filter_key}”有误。",
- "diagnosis_apps_outdated_ynh_requirement": "此应用程序的安装 版本只需要 yunohost >= 2.x,这往往表明它与推荐的打包实践和帮助程序不是最新的。你真的应该考虑更新它。",
- "disk_space_not_sufficient_install": "没有足够的磁盘空间来安装此应用程序",
+ "diagnosis_apps_outdated_ynh_requirement": "此应用的安装 版本只需要 yunohost >= 2.x,这往往表明它与推荐的打包实践和帮助程序不是最新的。您真的应该考虑更新它。",
+ "disk_space_not_sufficient_install": "没有足够的磁盘空间来安装此应用",
"config_apply_failed": "应用新配置 失败:{error}",
"config_cant_set_value_on_section": "无法在整个配置部分设置单个值 。",
"config_validate_color": "是有效的 RGB 十六进制颜色",
@@ -571,10 +570,9 @@
"config_validate_email": "是有效的电子邮件",
"config_validate_time": "应该是像 HH:MM 这样的有效时间",
"config_validate_url": "应该是有效的URL",
- "config_version_not_supported": "不支持配置面板版本“{ version }”。",
"danger": "警告:",
- "diagnosis_apps_allgood": "所有已安装的应用程序都遵守基本的打包原则",
- "diagnosis_apps_deprecated_practices": "此应用程序的安装 版本仍然使用一些超旧的弃用打包原则。推荐您升级它。",
+ "diagnosis_apps_allgood": "所有已安装的应用都遵守基本的打包原则",
+ "diagnosis_apps_deprecated_practices": "此应用的安装 版本仍然使用一些超旧的弃用打包原则。推荐您升级它。",
"diagnosis_apps_issue": "发现应用{ app } 存在问题",
"diagnosis_description_apps": "应用",
"global_settings_setting_backup_compress_tar_archives_help": "创建新备份时,请压缩档案(.tar.gz) ,而不要压缩未压缩的档案(.tar)。注意:启用此选项意味着创建较小的备份存档,但是初始备份过程将明显更长且占用大量CPU。",
@@ -585,12 +583,12 @@
"global_settings_setting_ssh_compatibility_help": "SSH服务器的兼容性与安全性的权衡。影响密码(以及其他与安全性有关的方面)",
"global_settings_setting_ssh_port": "SSH端口",
"global_settings_setting_smtp_allow_ipv6_help": "允许使用IPv6接收和发送邮件",
- "global_settings_setting_smtp_relay_enabled_help": "使用SMTP中继主机来代替这个YunoHost实例发送邮件。如果你有以下情况,就很有用:你的25端口被你的ISP或VPS提供商封锁,你有一个住宅IP列在DUHL上,你不能配置反向DNS,或者这个服务器没有直接暴露在互联网上,你想使用其他服务器来发送邮件。",
+ "global_settings_setting_smtp_relay_enabled_help": "使用SMTP中继主机来代替这个YunoHost实例发送邮件。如果您有以下情况,就很有用:您的25端口被您的ISP或VPS提供商封锁,您有一个住宅IP列在DUHL上,您不能配置反向DNS,或者这个服务器没有直接暴露在互联网上,您想使用其他服务器来发送邮件。",
"all_users": "所有的YunoHost用户",
- "app_manifest_install_ask_init_admin_permission": "谁应该有权访问此应用程序的管理功能?(此配置可以稍后更改)",
+ "app_manifest_install_ask_init_admin_permission": "谁应该有权访问此应用的管理功能?(此配置可以稍后更改)",
"app_action_failed": "对应用{app}执行动作{action}失败",
- "app_manifest_install_ask_init_main_permission": "谁应该有权访问此应用程序?(此配置稍后可以更改)",
+ "app_manifest_install_ask_init_main_permission": "谁应该有权访问此应用?(此配置稍后可以更改)",
"ask_admin_fullname": "管理员全名",
"ask_admin_username": "管理员用户名",
"ask_fullname": "全名"
-}
+}
\ No newline at end of file
diff --git a/maintenance/autofix_locale_format.py b/maintenance/autofix_locale_format.py
index 1c56ea386..caa36f9f2 100644
--- a/maintenance/autofix_locale_format.py
+++ b/maintenance/autofix_locale_format.py
@@ -32,7 +32,6 @@ def autofix_i18n_placeholders():
# We iterate over all keys/string in en.json
for key, string in reference.items():
-
# Ignore check if there's no translation yet for this key
if key not in this_locale:
continue
@@ -89,7 +88,6 @@ Please fix it manually !
def autofix_orthotypography_and_standardized_words():
def reformat(lang, transformations):
-
locale = open(f"{LOCALE_FOLDER}{lang}.json").read()
for pattern, replace in transformations.items():
locale = re.compile(pattern).sub(replace, locale)
@@ -146,11 +144,9 @@ def autofix_orthotypography_and_standardized_words():
def remove_stale_translated_strings():
-
reference = json.loads(open(LOCALE_FOLDER + "en.json").read())
for locale_file in TRANSLATION_FILES:
-
print(locale_file)
this_locale = json.loads(
open(LOCALE_FOLDER + locale_file).read(), object_pairs_hook=OrderedDict
diff --git a/maintenance/missing_i18n_keys.py b/maintenance/missing_i18n_keys.py
index f49fc923e..2ed7fd141 100644
--- a/maintenance/missing_i18n_keys.py
+++ b/maintenance/missing_i18n_keys.py
@@ -19,7 +19,6 @@ REFERENCE_FILE = LOCALE_FOLDER + "en.json"
def find_expected_string_keys():
-
# Try to find :
# m18n.n( "foo"
# YunohostError("foo"
@@ -197,7 +196,6 @@ undefined_keys = sorted(undefined_keys)
mode = sys.argv[1].strip("-")
if mode == "check":
-
# Unused keys are not too problematic, will be automatically
# removed by the other autoreformat script,
# but still informative to display them
diff --git a/share/actionsmap.yml b/share/actionsmap.yml
index 9fe077c23..412297440 100644
--- a/share/actionsmap.yml
+++ b/share/actionsmap.yml
@@ -72,7 +72,7 @@ user:
ask: ask_fullname
required: False
pattern: &pattern_fullname
- - !!str ^([^\W\d_]{1,30}[ ,.'-]{0,3})+$
+ - !!str ^([^\W_]{1,30}[ ,.'-]{0,3})+$
- "pattern_fullname"
-f:
full: --firstname
@@ -116,6 +116,11 @@ user:
pattern: &pattern_mailbox_quota
- !!str ^(\d+[bkMGT])|0$
- "pattern_mailbox_quota"
+ -s:
+ full: --loginShell
+ help: The login shell used
+ default: "/bin/bash"
+
### user_delete()
delete:
@@ -195,6 +200,10 @@ user:
metavar: "{SIZE|0}"
extra:
pattern: *pattern_mailbox_quota
+ -s:
+ full: --loginShell
+ help: The login shell used
+ default: "/bin/bash"
### user_info()
info:
@@ -961,6 +970,10 @@ app:
full: --no-safety-backup
help: Disable the safety backup during upgrade
action: store_true
+ -c:
+ full: --continue-on-failure
+ help: Continue to upgrade apps event if one or more upgrade failed
+ action: store_true
### app_change_url()
change-url:
diff --git a/share/config_domain.toml b/share/config_domain.toml
index b1ec436c5..82ef90c32 100644
--- a/share/config_domain.toml
+++ b/share/config_domain.toml
@@ -9,8 +9,6 @@ name = "Features"
type = "app"
filter = "is_webapp"
default = "_none"
- # FIXME: i18n
- help = "People will automatically be redirected to this app when opening this domain. If no app is specified, people are redirected to the user portal login form."
[feature.mail]
@@ -27,8 +25,6 @@ name = "Features"
[feature.xmpp.xmpp]
type = "boolean"
default = 0
- # FIXME: i18n
- help = "NB: some XMPP features will require that you update your DNS records and regenerate your Lets Encrypt certificate to be enabled"
[dns]
name = "DNS"
@@ -64,24 +60,23 @@ name = "Certificate"
[cert.cert.acme_eligible_explain]
type = "alert"
style = "warning"
- visible = "acme_eligible == false || acme_elligible == null"
+ visible = "acme_eligible == false || acme_eligible == null"
[cert.cert.cert_no_checks]
- ask = "Ignore diagnosis checks"
type = "boolean"
default = false
- visible = "acme_eligible == false || acme_elligible == null"
+ visible = "acme_eligible == false || acme_eligible == null"
[cert.cert.cert_install]
type = "button"
icon = "star"
style = "success"
- visible = "issuer != 'letsencrypt'"
+ visible = "cert_issuer != 'letsencrypt'"
enabled = "acme_eligible || cert_no_checks"
[cert.cert.cert_renew]
type = "button"
icon = "refresh"
style = "warning"
- visible = "issuer == 'letsencrypt'"
+ visible = "cert_issuer == 'letsencrypt'"
enabled = "acme_eligible || cert_no_checks"
diff --git a/share/config_global.toml b/share/config_global.toml
index 1f3cc1b39..40b71ab19 100644
--- a/share/config_global.toml
+++ b/share/config_global.toml
@@ -160,3 +160,12 @@ name = "Other"
[misc.backup.backup_compress_tar_archives]
type = "boolean"
default = false
+
+ [misc.network]
+ name = "Network"
+ [misc.network.dns_exposure]
+ type = "select"
+ choices.both = "Both"
+ choices.ipv4 = "IPv4 Only"
+ choices.ipv6 = "IPv6 Only"
+ default = "both"
diff --git a/share/registrar_list.toml b/share/registrar_list.toml
index 01906becd..3f478a03f 100644
--- a/share/registrar_list.toml
+++ b/share/registrar_list.toml
@@ -501,6 +501,15 @@
[pointhq.auth_token]
type = "string"
redact = true
+
+[porkbun]
+ [porkbun.auth_key]
+ type = "string"
+ redact = true
+
+ [porkbun.auth_secret]
+ type = "string"
+ redact = true
[powerdns]
[powerdns.auth_token]
diff --git a/src/__init__.py b/src/__init__.py
index af18e1fe4..d13d61089 100644
--- a/src/__init__.py
+++ b/src/__init__.py
@@ -1,6 +1,6 @@
#! /usr/bin/python
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -32,7 +32,6 @@ def is_installed():
def cli(debug, quiet, output_as, timeout, args, parser):
-
init_logging(interface="cli", debug=debug, quiet=quiet)
# Check that YunoHost is installed
@@ -51,7 +50,6 @@ def cli(debug, quiet, output_as, timeout, args, parser):
def api(debug, host, port):
-
init_logging(interface="api", debug=debug)
def is_installed_api():
@@ -71,7 +69,6 @@ def api(debug, host, port):
def check_command_is_valid_before_postinstall(args):
-
allowed_if_not_postinstalled = [
"tools postinstall",
"tools versions",
@@ -109,7 +106,6 @@ def init_i18n():
def init_logging(interface="cli", debug=False, quiet=False, logdir="/var/log/yunohost"):
-
logfile = os.path.join(logdir, "yunohost-%s.log" % interface)
if not os.path.isdir(logdir):
diff --git a/src/app.py b/src/app.py
index ed1432685..1daa14d98 100644
--- a/src/app.py
+++ b/src/app.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -29,7 +29,7 @@ import subprocess
import tempfile
import copy
from collections import OrderedDict
-from typing import List, Tuple, Dict, Any, Iterator
+from typing import List, Tuple, Dict, Any, Iterator, Optional
from packaging import version
from moulinette import Moulinette, m18n
@@ -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,
@@ -62,6 +61,7 @@ from yunohost.utils.system import (
dpkg_is_broken,
get_ynh_package_version,
system_arch,
+ debian_version,
human_to_binary,
binary_to_human,
ram_available,
@@ -238,7 +238,6 @@ def app_info(app, full=False, upgradable=False):
def _app_upgradable(app_infos):
-
# Determine upgradability
app_in_catalog = app_infos.get("from_catalog")
@@ -374,7 +373,6 @@ def app_map(app=None, raw=False, user=None):
)
for url in perm_all_urls:
-
# Here, we decide to completely ignore regex-type urls ...
# Because :
# - displaying them in regular "yunohost app map" output creates
@@ -413,7 +411,7 @@ def app_change_url(operation_logger, app, domain, path):
path -- New path at which the application will be move
"""
- from yunohost.hook import hook_exec, hook_callback
+ from yunohost.hook import hook_exec_with_script_debug_if_failure, hook_callback
from yunohost.service import service_reload_or_restart
installed = _is_installed(app)
@@ -447,6 +445,8 @@ def app_change_url(operation_logger, app, domain, path):
_validate_webpath_requirement(
{"domain": domain, "path": path}, path_requirement, ignore_app=app
)
+ if path_requirement == "full_domain" and path != "/":
+ raise YunohostValidationError("app_change_url_require_full_domain", app=app)
tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
@@ -454,46 +454,93 @@ def app_change_url(operation_logger, app, domain, path):
env_dict = _make_environment_for_app_script(
app, workdir=tmp_workdir_for_app, action="change_url"
)
+
env_dict["YNH_APP_OLD_DOMAIN"] = old_domain
env_dict["YNH_APP_OLD_PATH"] = old_path
env_dict["YNH_APP_NEW_DOMAIN"] = domain
env_dict["YNH_APP_NEW_PATH"] = path
+ env_dict["old_domain"] = old_domain
+ env_dict["old_path"] = old_path
+ env_dict["new_domain"] = domain
+ env_dict["new_path"] = path
+ env_dict["change_path"] = "1" if old_path != path else "0"
+ env_dict["change_domain"] = "1" if old_domain != domain else "0"
+
if domain != old_domain:
operation_logger.related_to.append(("domain", old_domain))
operation_logger.extra.update({"env": env_dict})
operation_logger.start()
+ old_nginx_conf_path = f"/etc/nginx/conf.d/{old_domain}.d/{app}.conf"
+ new_nginx_conf_path = f"/etc/nginx/conf.d/{domain}.d/{app}.conf"
+ old_nginx_conf_backup = None
+ if not os.path.exists(old_nginx_conf_path):
+ logger.warning(
+ f"Current nginx config file {old_nginx_conf_path} doesn't seem to exist ... wtf ?"
+ )
+ else:
+ old_nginx_conf_backup = read_file(old_nginx_conf_path)
+
change_url_script = os.path.join(tmp_workdir_for_app, "scripts/change_url")
# Execute App change_url script
- ret = hook_exec(change_url_script, env=env_dict)[0]
- if ret != 0:
- msg = f"Failed to change '{app}' url."
- logger.error(msg)
- operation_logger.error(msg)
+ change_url_failed = True
+ try:
+ (
+ change_url_failed,
+ failure_message_with_debug_instructions,
+ ) = hook_exec_with_script_debug_if_failure(
+ change_url_script,
+ env=env_dict,
+ operation_logger=operation_logger,
+ error_message_if_script_failed=m18n.n("app_change_url_script_failed"),
+ error_message_if_failed=lambda e: m18n.n(
+ "app_change_url_failed", app=app, error=e
+ ),
+ )
+ finally:
+ shutil.rmtree(tmp_workdir_for_app)
- # restore values modified by app_checkurl
- # see begining of the function
- app_setting(app, "domain", value=old_domain)
- app_setting(app, "path", value=old_path)
- return
- shutil.rmtree(tmp_workdir_for_app)
+ if change_url_failed:
+ logger.warning("Restoring initial nginx config file")
+ if old_nginx_conf_path != new_nginx_conf_path and os.path.exists(
+ new_nginx_conf_path
+ ):
+ rm(new_nginx_conf_path, force=True)
+ if old_nginx_conf_backup:
+ write_to_file(old_nginx_conf_path, old_nginx_conf_backup)
+ service_reload_or_restart("nginx")
- # this should idealy be done in the change_url script but let's avoid common mistakes
- app_setting(app, "domain", value=domain)
- app_setting(app, "path", value=path)
+ # restore values modified by app_checkurl
+ # see begining of the function
+ app_setting(app, "domain", value=old_domain)
+ app_setting(app, "path", value=old_path)
+ raise YunohostError(failure_message_with_debug_instructions, raw_msg=True)
+ else:
+ # make sure the domain/path setting are propagated
+ app_setting(app, "domain", value=domain)
+ app_setting(app, "path", value=path)
- app_ssowatconf()
+ app_ssowatconf()
- service_reload_or_restart("nginx")
+ service_reload_or_restart("nginx")
- logger.success(m18n.n("app_change_url_success", app=app, domain=domain, path=path))
+ logger.success(
+ m18n.n("app_change_url_success", app=app, domain=domain, path=path)
+ )
- hook_callback("post_app_change_url", env=env_dict)
+ hook_callback("post_app_change_url", env=env_dict)
-def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False):
+def app_upgrade(
+ app=[],
+ url=None,
+ file=None,
+ force=False,
+ no_safety_backup=False,
+ continue_on_failure=False,
+):
"""
Upgrade app
@@ -545,6 +592,7 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
logger.info(m18n.n("app_upgrade_several_apps", apps=", ".join(apps)))
notifications = {}
+ failed_to_upgrade_apps = []
for number, app_instance_name in enumerate(apps):
logger.info(m18n.n("app_upgrade_app_name", app=app_instance_name))
@@ -649,7 +697,9 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
safety_backup_name = f"{app_instance_name}-pre-upgrade2"
other_safety_backup_name = f"{app_instance_name}-pre-upgrade1"
- backup_create(name=safety_backup_name, apps=[app_instance_name])
+ backup_create(
+ name=safety_backup_name, apps=[app_instance_name], system=None
+ )
if safety_backup_name in backup_list()["archives"]:
# if the backup suceeded, delete old safety backup to save space
@@ -679,11 +729,17 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
env_dict = _make_environment_for_app_script(
app_instance_name, workdir=extracted_app_folder, action="upgrade"
)
- env_dict["YNH_APP_UPGRADE_TYPE"] = upgrade_type
- env_dict["YNH_APP_MANIFEST_VERSION"] = str(app_new_version)
- env_dict["YNH_APP_CURRENT_VERSION"] = str(app_current_version)
+
+ env_dict_more = {
+ "YNH_APP_UPGRADE_TYPE": upgrade_type,
+ "YNH_APP_MANIFEST_VERSION": str(app_new_version),
+ "YNH_APP_CURRENT_VERSION": str(app_current_version),
+ }
+
if manifest["packaging_format"] < 2:
- env_dict["NO_BACKUP_UPGRADE"] = "1" if no_safety_backup else "0"
+ env_dict_more["NO_BACKUP_UPGRADE"] = "1" if no_safety_backup else "0"
+
+ env_dict.update(env_dict_more)
# Start register change on system
related_to = [("app", app_instance_name)]
@@ -698,8 +754,20 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
).apply(
rollback_and_raise_exception_if_failure=True,
operation_logger=operation_logger,
+ action="upgrade",
)
+ # Boring stuff : the resource upgrade may have added/remove/updated setting
+ # so we need to reflect this in the env_dict used to call the actual upgrade script x_x
+ # Or: the old manifest may be in v1 and the new in v2, so force to add the setting in env
+ env_dict = _make_environment_for_app_script(
+ app_instance_name,
+ workdir=extracted_app_folder,
+ action="upgrade",
+ force_include_app_settings=True,
+ )
+ env_dict.update(env_dict_more)
+
# Execute the app upgrade script
upgrade_failed = True
try:
@@ -716,7 +784,6 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
),
)
finally:
-
# If upgrade failed, try to restore the safety backup
if (
upgrade_failed
@@ -727,7 +794,7 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
"Upgrade failed ... attempting to restore the satefy backup (Yunohost first need to remove the app for this) ..."
)
- app_remove(app_instance_name)
+ app_remove(app_instance_name, force_workdir=extracted_app_folder)
backup_restore(
name=safety_backup_name, apps=[app_instance_name], force=True
)
@@ -762,21 +829,51 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
# If upgrade failed or broke the system,
# raise an error and interrupt all other pending upgrades
if upgrade_failed or broke_the_system:
+ if not continue_on_failure or broke_the_system:
+ # display this if there are remaining apps
+ if apps[number + 1 :]:
+ not_upgraded_apps = apps[number:]
+ if broke_the_system and not continue_on_failure:
+ logger.error(
+ m18n.n(
+ "app_not_upgraded_broken_system",
+ failed_app=app_instance_name,
+ apps=", ".join(not_upgraded_apps),
+ )
+ )
+ elif broke_the_system and continue_on_failure:
+ logger.error(
+ m18n.n(
+ "app_not_upgraded_broken_system_continue",
+ failed_app=app_instance_name,
+ apps=", ".join(not_upgraded_apps),
+ )
+ )
+ else:
+ logger.error(
+ m18n.n(
+ "app_not_upgraded",
+ failed_app=app_instance_name,
+ apps=", ".join(not_upgraded_apps),
+ )
+ )
- # display this if there are remaining apps
- if apps[number + 1 :]:
- not_upgraded_apps = apps[number:]
- logger.error(
- m18n.n(
- "app_not_upgraded",
- failed_app=app_instance_name,
- apps=", ".join(not_upgraded_apps),
- )
+ raise YunohostError(
+ failure_message_with_debug_instructions, raw_msg=True
)
- raise YunohostError(
- failure_message_with_debug_instructions, raw_msg=True
- )
+ else:
+ operation_logger.close()
+ logger.error(
+ m18n.n(
+ "app_failed_to_upgrade_but_continue",
+ failed_app=app_instance_name,
+ operation_logger_name=operation_logger.name,
+ )
+ )
+ failed_to_upgrade_apps.append(
+ (app_instance_name, operation_logger.name)
+ )
# Otherwise we're good and keep going !
now = int(time.time())
@@ -838,12 +935,22 @@ def app_upgrade(app=[], url=None, file=None, force=False, no_safety_backup=False
logger.success(m18n.n("upgrade_complete"))
+ if failed_to_upgrade_apps:
+ apps = ""
+ for app_id, operation_logger_name in failed_to_upgrade_apps:
+ apps += m18n.n(
+ "apps_failed_to_upgrade_line",
+ app_id=app_id,
+ operation_logger_name=operation_logger_name,
+ )
+
+ logger.warning(m18n.n("apps_failed_to_upgrade", apps=apps))
+
if Moulinette.interface.type == "api":
return {"notifications": {"POST_UPGRADE": notifications}}
def app_manifest(app, with_screenshot=False):
-
manifest, extracted_app_folder = _extract_app(app)
raw_questions = manifest.get("install", {}).values()
@@ -886,7 +993,6 @@ def app_manifest(app, with_screenshot=False):
def _confirm_app_install(app, force=False):
-
# Ignore if there's nothing for confirm (good quality app), if --force is used
# or if request on the API (confirm already implemented on the API side)
if force or Moulinette.interface.type == "api":
@@ -1036,8 +1142,8 @@ def app_install(
# If packaging_format v2+, save all install questions as settings
if packaging_format >= 2:
for question in questions:
-
# Except user-provider passwords
+ # ... which we need to reinject later in the env_dict
if question.type == "password":
continue
@@ -1062,10 +1168,15 @@ def app_install(
if packaging_format >= 2:
from yunohost.utils.resources import AppResourceManager
- AppResourceManager(app_instance_name, wanted=manifest, current={}).apply(
- rollback_and_raise_exception_if_failure=True,
- operation_logger=operation_logger,
- )
+ try:
+ AppResourceManager(app_instance_name, wanted=manifest, current={}).apply(
+ rollback_and_raise_exception_if_failure=True,
+ operation_logger=operation_logger,
+ action="install",
+ )
+ except (KeyboardInterrupt, EOFError, Exception) as e:
+ shutil.rmtree(app_setting_path)
+ raise e
else:
# Initialize the main permission for the app
# The permission is initialized with no url associated, and with tile disabled
@@ -1085,11 +1196,22 @@ def app_install(
app_instance_name, args=args, workdir=extracted_app_folder, action="install"
)
+ # If packaging_format v2+, save all install questions as settings
+ if packaging_format >= 2:
+ for question in questions:
+ # Reinject user-provider passwords which are not in the app settings
+ # (cf a few line before)
+ if question.type == "password":
+ env_dict[question.name] = question.value
+
+ # We want to hav the env_dict in the log ... but not password values
env_dict_for_logging = env_dict.copy()
for question in questions:
# Or should it be more generally question.redact ?
if question.type == "password":
del env_dict_for_logging[f"YNH_APP_ARG_{question.name.upper()}"]
+ if question.name in env_dict_for_logging:
+ del env_dict_for_logging[question.name]
operation_logger.extra.update({"env": env_dict_for_logging})
@@ -1135,7 +1257,6 @@ def app_install(
# If the install failed or broke the system, we remove it
if install_failed or broke_the_system:
-
# This option is meant for packagers to debug their apps more easily
if no_remove_on_failure:
raise YunohostError(
@@ -1183,7 +1304,7 @@ def app_install(
AppResourceManager(
app_instance_name, wanted={}, current=manifest
- ).apply(rollback_and_raise_exception_if_failure=False)
+ ).apply(rollback_and_raise_exception_if_failure=False, action="remove")
else:
# Remove all permission in LDAP
for permission_name in user_permission_list()["permissions"].keys():
@@ -1243,14 +1364,14 @@ def app_install(
@is_unit_operation()
-def app_remove(operation_logger, app, purge=False):
+def app_remove(operation_logger, app, purge=False, force_workdir=None):
"""
Remove app
Keyword arguments:
app -- App(s) to delete
purge -- Remove with all app data
-
+ force_workdir -- Special var to force the working directoy to use, in context such as remove-after-failed-upgrade or remove-after-failed-restore
"""
from yunohost.utils.legacy import _patch_legacy_php_versions, _patch_legacy_helpers
from yunohost.hook import hook_exec, hook_remove, hook_callback
@@ -1269,7 +1390,6 @@ def app_remove(operation_logger, app, purge=False):
operation_logger.start()
logger.info(m18n.n("app_start_remove", app=app))
-
app_setting_path = os.path.join(APPS_SETTING_PATH, app)
# Attempt to patch legacy helpers ...
@@ -1279,8 +1399,20 @@ def app_remove(operation_logger, app, purge=False):
# script might date back from jessie install)
_patch_legacy_php_versions(app_setting_path)
- manifest = _get_manifest_of_app(app_setting_path)
- tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
+ if force_workdir:
+ # This is when e.g. calling app_remove() from the upgrade-failed case
+ # where we want to remove using the *new* remove script and not the old one
+ # and also get the new manifest
+ # It's especially important during v1->v2 app format transition where the
+ # setting names change (e.g. install_dir instead of final_path) and
+ # running the old remove script doesnt make sense anymore ...
+ tmp_workdir_for_app = tempfile.mkdtemp(prefix="app_", dir=APP_TMP_WORKDIRS)
+ os.system(f"cp -a {force_workdir}/* {tmp_workdir_for_app}/")
+ else:
+ tmp_workdir_for_app = _make_tmp_workdir_for_app(app=app)
+
+ manifest = _get_manifest_of_app(tmp_workdir_for_app)
+
remove_script = f"{tmp_workdir_for_app}/scripts/remove"
env_dict = {}
@@ -1311,7 +1443,9 @@ def app_remove(operation_logger, app, purge=False):
from yunohost.utils.resources import AppResourceManager
AppResourceManager(app, wanted={}, current=manifest).apply(
- rollback_and_raise_exception_if_failure=False, purge_data_dir=purge
+ rollback_and_raise_exception_if_failure=False,
+ purge_data_dir=purge,
+ action="remove",
)
else:
# Remove all permission in LDAP
@@ -1390,7 +1524,6 @@ def app_setting(app, key, value=None, delete=False):
)
if is_legacy_permission_setting:
-
from yunohost.permission import (
user_permission_list,
user_permission_update,
@@ -1433,7 +1566,6 @@ def app_setting(app, key, value=None, delete=False):
# SET
else:
-
urls = value
# If the request is about the root of the app (/), ( = the vast majority of cases)
# we interpret this as a change for the main permission
@@ -1445,7 +1577,6 @@ def app_setting(app, key, value=None, delete=False):
else:
user_permission_update(app + ".main", remove="visitors")
else:
-
urls = urls.split(",")
if key.endswith("_regex"):
urls = ["re:" + url for url in urls]
@@ -1595,8 +1726,15 @@ def app_ssowatconf():
}
redirected_urls = {}
- for app in _installed_apps():
+ apps_using_remote_user_var_in_nginx = (
+ check_output(
+ "grep -nri '$remote_user' /etc/yunohost/apps/*/conf/*nginx*conf | awk -F/ '{print $5}' || true"
+ )
+ .strip()
+ .split("\n")
+ )
+ for app in _installed_apps():
app_settings = read_yaml(APPS_SETTING_PATH + app + "/settings.yml") or {}
# Redirected
@@ -1622,7 +1760,6 @@ def app_ssowatconf():
# New permission system
for perm_name, perm_info in all_permissions.items():
-
uris = (
[]
+ ([perm_info["url"]] if perm_info["url"] else [])
@@ -1633,7 +1770,11 @@ def app_ssowatconf():
if not uris:
continue
+ app_id = perm_name.split(".")[0]
+
permissions[perm_name] = {
+ "use_remote_user_var_in_nginx_conf": app_id
+ in apps_using_remote_user_var_in_nginx,
"users": perm_info["corresponding_users"],
"label": perm_info["label"],
"show_tile": perm_info["show_tile"]
@@ -1682,13 +1823,11 @@ def app_change_label(app, new_label):
def app_action_list(app):
-
return AppConfigPanel(app).list_actions()
@is_unit_operation()
def app_action_run(operation_logger, app, action, args=None, args_file=None):
-
return AppConfigPanel(app).run_action(
action, args=args, args_file=args_file, operation_logger=operation_logger
)
@@ -2024,12 +2163,10 @@ def _get_manifest_of_app(path):
def _parse_app_doc_and_notifications(path):
-
doc = {}
notification_names = ["PRE_INSTALL", "POST_INSTALL", "PRE_UPGRADE", "POST_UPGRADE"]
for filepath in glob.glob(os.path.join(path, "doc") + "/*.md"):
-
# to be improved : [a-z]{2,3} is a clumsy way of parsing the
# lang code ... some lang code are more complex that this é_è
m = re.match("([A-Z]*)(_[a-z]{2,3})?.md", filepath.split("/")[-1])
@@ -2047,7 +2184,12 @@ def _parse_app_doc_and_notifications(path):
if pagename not in doc:
doc[pagename] = {}
- doc[pagename][lang] = read_file(filepath).strip()
+
+ try:
+ doc[pagename][lang] = read_file(filepath).strip()
+ except Exception as e:
+ logger.error(e)
+ continue
notifications = {}
@@ -2061,7 +2203,11 @@ def _parse_app_doc_and_notifications(path):
lang = m.groups()[0].strip("_") if m.groups()[0] else "en"
if pagename not in notifications[step]:
notifications[step][pagename] = {}
- notifications[step][pagename][lang] = read_file(filepath).strip()
+ try:
+ notifications[step][pagename][lang] = read_file(filepath).strip()
+ except Exception as e:
+ logger.error(e)
+ continue
for filepath in glob.glob(os.path.join(path, "doc", f"{step}.d") + "/*.md"):
m = re.match(
@@ -2073,27 +2219,29 @@ def _parse_app_doc_and_notifications(path):
lang = lang.strip("_") if lang else "en"
if pagename not in notifications[step]:
notifications[step][pagename] = {}
- notifications[step][pagename][lang] = read_file(filepath).strip()
+
+ try:
+ notifications[step][pagename][lang] = read_file(filepath).strip()
+ except Exception as e:
+ logger.error(e)
+ continue
return doc, notifications
def _hydrate_app_template(template, data):
-
stuff_to_replace = set(re.findall(r"__[A-Z0-9]+?[A-Z0-9_]*?[A-Z0-9]*?__", template))
for stuff in stuff_to_replace:
-
varname = stuff.strip("_").lower()
if varname in data:
- template = template.replace(stuff, data[varname])
+ template = template.replace(stuff, str(data[varname]))
return template
def _convert_v1_manifest_to_v2(manifest):
-
manifest = copy.deepcopy(manifest)
if "upstream" not in manifest:
@@ -2174,7 +2322,6 @@ def _convert_v1_manifest_to_v2(manifest):
def _set_default_ask_questions(questions, script_name="install"):
-
# arguments is something like
# { "domain":
# {
@@ -2232,7 +2379,6 @@ def _set_default_ask_questions(questions, script_name="install"):
def _is_app_repo_url(string: str) -> bool:
-
string = string.strip()
# Dummy test for ssh-based stuff ... should probably be improved somehow
@@ -2249,7 +2395,6 @@ def _app_quality(src: str) -> str:
raw_app_catalog = _load_apps_catalog()["apps"]
if src in raw_app_catalog or _is_app_repo_url(src):
-
# If we got an app name directly (e.g. just "wordpress"), we gonna test this name
if src in raw_app_catalog:
app_name_to_test = src
@@ -2262,7 +2407,6 @@ def _app_quality(src: str) -> str:
return "thirdparty"
if app_name_to_test in raw_app_catalog:
-
state = raw_app_catalog[app_name_to_test].get("state", "notworking")
level = raw_app_catalog[app_name_to_test].get("level", None)
if state in ["working", "validated"]:
@@ -2300,19 +2444,21 @@ def _extract_app(src: str) -> Tuple[Dict, str]:
url = app_info["git"]["url"]
branch = app_info["git"]["branch"]
revision = str(app_info["git"]["revision"])
- return _extract_app_from_gitrepo(url, branch, revision, app_info)
+ return _extract_app_from_gitrepo(
+ url, branch=branch, revision=revision, app_info=app_info
+ )
# App is a git repo url
elif _is_app_repo_url(src):
url = src.strip().strip("/")
- branch = "master"
- revision = "HEAD"
# gitlab urls may look like 'https://domain/org/group/repo/-/tree/testing'
# compated to github urls looking like 'https://domain/org/repo/tree/testing'
if "/-/" in url:
url = url.replace("/-/", "/")
if "/tree/" in url:
url, branch = url.split("/tree/", 1)
- return _extract_app_from_gitrepo(url, branch, revision, {})
+ else:
+ branch = None
+ return _extract_app_from_gitrepo(url, branch=branch)
# App is a local folder
elif os.path.exists(src):
return _extract_app_from_folder(src)
@@ -2342,6 +2488,10 @@ def _extract_app_from_folder(path: str) -> Tuple[Dict, str]:
if path[-1] != "/":
path = path + "/"
cp(path, extracted_app_folder, recursive=True)
+ # Change the last edit time which is used in _make_tmp_workdir_for_app
+ # to cleanup old dir ... otherwise it may end up being incorrectly removed
+ # at the end of the safety-backup-before-upgrade :/
+ os.system(f"touch {extracted_app_folder}")
else:
try:
shutil.unpack_archive(path, extracted_app_folder)
@@ -2369,8 +2519,43 @@ def _extract_app_from_folder(path: str) -> Tuple[Dict, str]:
def _extract_app_from_gitrepo(
- url: str, branch: str, revision: str, app_info: Dict = {}
+ url: str, branch: Optional[str] = None, revision: str = "HEAD", app_info: Dict = {}
) -> Tuple[Dict, str]:
+ logger.debug("Checking default branch")
+
+ try:
+ git_ls_remote = check_output(
+ ["git", "ls-remote", "--symref", url, "HEAD"],
+ env={"GIT_TERMINAL_PROMPT": "0", "LC_ALL": "C"},
+ shell=False,
+ )
+ except Exception as e:
+ logger.error(str(e))
+ raise YunohostError("app_sources_fetch_failed")
+
+ if not branch:
+ default_branch = None
+ try:
+ for line in git_ls_remote.split("\n"):
+ # Look for the line formated like :
+ # ref: refs/heads/master HEAD
+ if "ref: refs/heads/" in line:
+ line = line.replace("/", " ").replace("\t", " ")
+ default_branch = line.split()[3]
+ except Exception:
+ pass
+
+ if not default_branch:
+ logger.warning("Failed to parse default branch, trying 'main'")
+ branch = "main"
+ else:
+ if default_branch in ["testing", "dev"]:
+ logger.warning(
+ f"Trying 'master' branch instead of default '{default_branch}'"
+ )
+ branch = "master"
+ else:
+ branch = default_branch
logger.debug(m18n.n("downloading"))
@@ -2522,7 +2707,7 @@ def _check_manifest_requirements(
yield (
"arch",
arch_requirement in ["all", "?"] or arch in arch_requirement,
- {"current": arch, "required": arch_requirement},
+ {"current": arch, "required": ", ".join(arch_requirement)},
"app_arch_not_supported", # i18n: app_arch_not_supported
)
@@ -2585,7 +2770,6 @@ def _check_manifest_requirements(
def _guess_webapp_path_requirement(app_folder: str) -> str:
-
# If there's only one "domain" and "path", validate that domain/path
# is an available url and normalize the path.
@@ -2608,22 +2792,33 @@ def _guess_webapp_path_requirement(app_folder: str) -> str:
if len(domain_questions) == 1 and len(path_questions) == 1:
return "domain_and_path"
if len(domain_questions) == 1 and len(path_questions) == 0:
- # This is likely to be a full-domain app...
+ if manifest.get("packaging_format", 0) < 2:
+ # This is likely to be a full-domain app...
- # Confirm that this is a full-domain app This should cover most cases
- # ... though anyway the proper solution is to implement some mechanism
- # in the manifest for app to declare that they require a full domain
- # (among other thing) so that we can dynamically check/display this
- # requirement on the webadmin form and not miserably fail at submit time
+ # Confirm that this is a full-domain app This should cover most cases
+ # ... though anyway the proper solution is to implement some mechanism
+ # in the manifest for app to declare that they require a full domain
+ # (among other thing) so that we can dynamically check/display this
+ # requirement on the webadmin form and not miserably fail at submit time
- # Full-domain apps typically declare something like path_url="/" or path=/
- # and use ynh_webpath_register or yunohost_app_checkurl inside the install script
- install_script_content = read_file(os.path.join(app_folder, "scripts/install"))
+ # Full-domain apps typically declare something like path_url="/" or path=/
+ # and use ynh_webpath_register or yunohost_app_checkurl inside the install script
+ install_script_content = read_file(
+ os.path.join(app_folder, "scripts/install")
+ )
- if re.search(
- r"\npath(_url)?=[\"']?/[\"']?", install_script_content
- ) and re.search(r"ynh_webpath_register", install_script_content):
- return "full_domain"
+ if re.search(
+ r"\npath(_url)?=[\"']?/[\"']?", install_script_content
+ ) and re.search(r"ynh_webpath_register", install_script_content):
+ return "full_domain"
+
+ else:
+ # For packaging v2 apps, check if there's a permission with url being a string
+ perm_resource = manifest.get("resources", {}).get("permissions")
+ if perm_resource is not None and isinstance(
+ perm_resource.get("main", {}).get("url"), str
+ ):
+ return "full_domain"
return "?"
@@ -2631,7 +2826,6 @@ def _guess_webapp_path_requirement(app_folder: str) -> str:
def _validate_webpath_requirement(
args: Dict[str, Any], path_requirement: str, ignore_app=None
) -> None:
-
domain = args.get("domain")
path = args.get("path")
@@ -2679,7 +2873,6 @@ def _get_conflicting_apps(domain, path, ignore_app=None):
def _assert_no_conflicting_apps(domain, path, ignore_app=None, full_domain=False):
-
conflicts = _get_conflicting_apps(domain, path, ignore_app)
if conflicts:
@@ -2696,12 +2889,17 @@ def _assert_no_conflicting_apps(domain, path, ignore_app=None, full_domain=False
def _make_environment_for_app_script(
- app, args={}, args_prefix="APP_ARG_", workdir=None, action=None
+ app,
+ args={},
+ args_prefix="APP_ARG_",
+ workdir=None,
+ action=None,
+ force_include_app_settings=False,
):
-
app_setting_path = os.path.join(APPS_SETTING_PATH, app)
- manifest = _get_manifest_of_app(app_setting_path)
+ manifest = _get_manifest_of_app(workdir if workdir else app_setting_path)
+
app_id, app_instance_nb = _parse_app_instance_name(app)
env_dict = {
@@ -2711,6 +2909,7 @@ def _make_environment_for_app_script(
"YNH_APP_MANIFEST_VERSION": manifest.get("version", "?"),
"YNH_APP_PACKAGING_FORMAT": str(manifest["packaging_format"]),
"YNH_ARCH": system_arch(),
+ "YNH_DEBIAN_VERSION": debian_version(),
}
if workdir:
@@ -2724,10 +2923,9 @@ def _make_environment_for_app_script(
env_dict[f"YNH_{args_prefix}{arg_name_upper}"] = str(arg_value)
# If packaging format v2, load all settings
- if manifest["packaging_format"] >= 2:
+ if manifest["packaging_format"] >= 2 or force_include_app_settings:
env_dict["app"] = app
for setting_name, setting_value in _get_app_settings(app).items():
-
# Ignore special internal settings like checksum__
# (not a huge deal to load them but idk...)
if setting_name.startswith("checksum__"):
@@ -2772,7 +2970,6 @@ def _parse_app_instance_name(app_instance_name: str) -> Tuple[str, int]:
def _next_instance_number_for_app(app):
-
# Get list of sibling apps, such as {app}, {app}__2, {app}__4
apps = _installed_apps()
sibling_app_ids = [a for a in apps if a == app or a.startswith(f"{app}__")]
@@ -2790,7 +2987,6 @@ def _next_instance_number_for_app(app):
def _make_tmp_workdir_for_app(app=None):
-
# Create parent dir if it doesn't exists yet
if not os.path.exists(APP_TMP_WORKDIRS):
os.makedirs(APP_TMP_WORKDIRS)
@@ -2820,12 +3016,10 @@ def _make_tmp_workdir_for_app(app=None):
def unstable_apps():
-
output = []
deprecated_apps = ["mailman", "ffsync"]
for infos in app_list(full=True)["apps"]:
-
if (
not infos.get("from_catalog")
or infos.get("from_catalog").get("state")
@@ -2841,7 +3035,6 @@ def unstable_apps():
def _assert_system_is_sane_for_app(manifest, when):
-
from yunohost.service import service_status
logger.debug("Checking that required services are up and running...")
@@ -2904,7 +3097,6 @@ def _assert_system_is_sane_for_app(manifest, when):
def app_dismiss_notification(app, name):
-
assert isinstance(name, str)
name = name.lower()
assert name in ["post_install", "post_upgrade"]
diff --git a/src/app_catalog.py b/src/app_catalog.py
index 5d4378544..9fb662845 100644
--- a/src/app_catalog.py
+++ b/src/app_catalog.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -157,7 +157,6 @@ def _read_apps_catalog_list():
def _actual_apps_catalog_api_url(base_url):
-
return f"{base_url}/v{APPS_CATALOG_API_VERSION}/apps.json"
@@ -269,7 +268,6 @@ def _load_apps_catalog():
merged_catalog = {"apps": {}, "categories": [], "antifeatures": []}
for apps_catalog_id in [L["id"] for L in _read_apps_catalog_list()]:
-
# Let's load the json from cache for this catalog
cache_file = f"{APPS_CATALOG_CACHE}/{apps_catalog_id}.json"
@@ -298,7 +296,6 @@ def _load_apps_catalog():
# Add apps from this catalog to the output
for app, info in apps_catalog_content["apps"].items():
-
# (N.B. : there's a small edge case where multiple apps catalog could be listing the same apps ...
# in which case we keep only the first one found)
if app in merged_catalog["apps"]:
diff --git a/src/authenticators/ldap_admin.py b/src/authenticators/ldap_admin.py
index 22b796e23..b1b550bc0 100644
--- a/src/authenticators/ldap_admin.py
+++ b/src/authenticators/ldap_admin.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -38,14 +38,12 @@ AUTH_DN = "uid={uid},ou=users,dc=yunohost,dc=org"
class Authenticator(BaseAuthenticator):
-
name = "ldap_admin"
def __init__(self, *args, **kwargs):
pass
def _authenticate_credentials(self, credentials=None):
-
try:
admins = (
_get_ldap_interface()
@@ -125,7 +123,6 @@ class Authenticator(BaseAuthenticator):
con.unbind_s()
def set_session_cookie(self, infos):
-
from bottle import response
assert isinstance(infos, dict)
@@ -145,7 +142,6 @@ class Authenticator(BaseAuthenticator):
)
def get_session_cookie(self, raise_if_no_session_exists=True):
-
from bottle import request
try:
@@ -174,7 +170,6 @@ class Authenticator(BaseAuthenticator):
return infos
def delete_session_cookie(self):
-
from bottle import response
response.set_cookie("yunohost.admin", "", max_age=-1)
diff --git a/src/backup.py b/src/backup.py
index c3e47bddc..ce1e8ba2c 100644
--- a/src/backup.py
+++ b/src/backup.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -32,6 +32,7 @@ from functools import reduce
from packaging import version
from moulinette import Moulinette, m18n
+from moulinette.utils.text import random_ascii
from moulinette.utils.log import getActionLogger
from moulinette.utils.filesystem import (
read_file,
@@ -51,6 +52,7 @@ from yunohost.app import (
_make_environment_for_app_script,
_make_tmp_workdir_for_app,
_get_manifest_of_app,
+ app_remove,
)
from yunohost.hook import (
hook_list,
@@ -93,7 +95,6 @@ class BackupRestoreTargetsManager:
"""
def __init__(self):
-
self.targets = {}
self.results = {"system": {}, "apps": {}}
@@ -349,7 +350,6 @@ class BackupManager:
if not os.path.isdir(self.work_dir):
mkdir(self.work_dir, 0o750, parents=True)
elif self.is_tmp_work_dir:
-
logger.debug(
"temporary directory for backup '%s' already exists... attempting to clean it",
self.work_dir,
@@ -887,7 +887,6 @@ class RestoreManager:
@property
def success(self):
-
successful_apps = self.targets.list("apps", include=["Success", "Warning"])
successful_system = self.targets.list("system", include=["Success", "Warning"])
@@ -939,7 +938,17 @@ class RestoreManager:
)
logger.debug("executing the post-install...")
- tools_postinstall(domain, "Yunohost", True)
+
+ # Use a dummy password which is not gonna be saved anywhere
+ # because the next thing to happen should be that a full restore of the LDAP db will happen
+ tools_postinstall(
+ domain,
+ "tmpadmin",
+ "Tmp Admin",
+ password=random_ascii(70),
+ ignore_dyndns=True,
+ overwrite_root_password=False,
+ )
def clean(self):
"""
@@ -1187,7 +1196,8 @@ class RestoreManager:
self._restore_apps()
except Exception as e:
raise YunohostError(
- f"The following critical error happened during restoration: {e}"
+ f"The following critical error happened during restoration: {e}",
+ raw_msg=True,
)
finally:
self.clean()
@@ -1366,8 +1376,6 @@ class RestoreManager:
from yunohost.user import user_group_list
from yunohost.permission import (
permission_create,
- permission_delete,
- user_permission_list,
permission_sync_to_user,
)
@@ -1443,7 +1451,6 @@ class RestoreManager:
existing_groups = user_group_list()["groups"]
for permission_name, permission_infos in permissions.items():
-
if "allowed" not in permission_infos:
logger.warning(
f"'allowed' key corresponding to allowed groups for permission {permission_name} not found when restoring app {app_instance_name} … You might have to reconfigure permissions yourself."
@@ -1521,6 +1528,7 @@ class RestoreManager:
AppResourceManager(app_instance_name, wanted=manifest, current={}).apply(
rollback_and_raise_exception_if_failure=True,
operation_logger=operation_logger,
+ action="restore",
)
# Execute the app install script
@@ -1547,39 +1555,9 @@ class RestoreManager:
self.targets.set_result("apps", app_instance_name, "Success")
operation_logger.success()
else:
-
self.targets.set_result("apps", app_instance_name, "Error")
- remove_script = os.path.join(app_scripts_in_archive, "remove")
-
- # Setup environment for remove script
- env_dict_remove = _make_environment_for_app_script(
- app_instance_name, workdir=app_workdir
- )
- remove_operation_logger = OperationLogger(
- "remove_on_failed_restore",
- [("app", app_instance_name)],
- env=env_dict_remove,
- )
- remove_operation_logger.start()
-
- # Execute remove script
- if hook_exec(remove_script, env=env_dict_remove)[0] != 0:
- msg = m18n.n("app_not_properly_removed", app=app_instance_name)
- logger.warning(msg)
- remove_operation_logger.error(msg)
- else:
- remove_operation_logger.success()
-
- # Cleaning app directory
- shutil.rmtree(app_settings_new_path, ignore_errors=True)
-
- # Remove all permission in LDAP for this app
- for permission_name in user_permission_list()["permissions"].keys():
- if permission_name.startswith(app_instance_name + "."):
- permission_delete(permission_name, force=True)
-
- # TODO Cleaning app hooks
+ app_remove(app_instance_name, force_workdir=app_workdir)
logger.error(failure_message_with_debug_instructions)
@@ -1938,12 +1916,10 @@ class CopyBackupMethod(BackupMethod):
class TarBackupMethod(BackupMethod):
-
method_name = "tar"
@property
def _archive_file(self):
-
if isinstance(self.manager, BackupManager) and settings_get(
"misc.backup.backup_compress_tar_archives"
):
@@ -2302,7 +2278,7 @@ def backup_create(
)
backup_manager.backup()
- logger.success(m18n.n("backup_created"))
+ logger.success(m18n.n("backup_created", name=backup_manager.name))
operation_logger.success()
return {
@@ -2400,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
@@ -2430,7 +2407,6 @@ def backup_list(with_info=False, human_readable=False):
def backup_download(name):
-
if Moulinette.interface.type != "api":
logger.error(
"This option is only meant for the API/webadmin and doesn't make sense for the command line."
@@ -2571,7 +2547,6 @@ def backup_info(name, with_details=False, human_readable=False):
if "size_details" in info.keys():
for category in ["apps", "system"]:
for name, key_info in info[category].items():
-
if category == "system":
# Stupid legacy fix for weird format between 3.5 and 3.6
if isinstance(key_info, dict):
@@ -2631,7 +2606,7 @@ def backup_delete(name):
hook_callback("post_backup_delete", args=[name])
- logger.success(m18n.n("backup_deleted"))
+ logger.success(m18n.n("backup_deleted", name=name))
#
diff --git a/src/certificate.py b/src/certificate.py
index 928bea499..52e0d8c1b 100644
--- a/src/certificate.py
+++ b/src/certificate.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -20,7 +20,7 @@ import os
import sys
import shutil
import subprocess
-import glob
+from glob import glob
from datetime import datetime
@@ -124,10 +124,8 @@ def certificate_install(domain_list, force=False, no_checks=False, self_signed=F
def _certificate_install_selfsigned(domain_list, force=False):
-
failed_cert_install = []
for domain in domain_list:
-
operation_logger = OperationLogger(
"selfsigned_cert_install", [("domain", domain)], args={"force": force}
)
@@ -238,7 +236,6 @@ def _certificate_install_letsencrypt(domains, force=False, no_checks=False):
# certificates
if domains == []:
for domain in domain_list()["domains"]:
-
status = _get_status(domain)
if status["CA_type"] != "selfsigned":
continue
@@ -260,7 +257,6 @@ def _certificate_install_letsencrypt(domains, force=False, no_checks=False):
# Actual install steps
failed_cert_install = []
for domain in domains:
-
if not no_checks:
try:
_check_domain_is_ready_for_ACME(domain)
@@ -317,7 +313,6 @@ def certificate_renew(domains, force=False, no_checks=False, email=False):
# certificates
if domains == []:
for domain in domain_list()["domains"]:
-
# Does it have a Let's Encrypt cert?
status = _get_status(domain)
if status["CA_type"] != "letsencrypt":
@@ -342,7 +337,6 @@ def certificate_renew(domains, force=False, no_checks=False, email=False):
# Else, validate the domain list given
else:
for domain in domains:
-
# Is it in Yunohost domain list?
_assert_domain_exists(domain)
@@ -369,7 +363,6 @@ def certificate_renew(domains, force=False, no_checks=False, email=False):
# Actual renew steps
failed_cert_install = []
for domain in domains:
-
if not no_checks:
try:
_check_domain_is_ready_for_ACME(domain)
@@ -468,13 +461,11 @@ investigate :
def _check_acme_challenge_configuration(domain):
-
domain_conf = f"/etc/nginx/conf.d/{domain}.conf"
return "include /etc/nginx/conf.d/acme-challenge.conf.inc" in read_file(domain_conf)
def _fetch_and_enable_new_certificate(domain, no_checks=False):
-
if not os.path.exists(ACCOUNT_KEY_FILE):
_generate_account_key()
@@ -628,7 +619,6 @@ def _prepare_certificate_signing_request(domain, key_file, output_folder):
def _get_status(domain):
-
cert_file = os.path.join(CERT_FOLDER, domain, "crt.pem")
if not os.path.isfile(cert_file):
@@ -744,10 +734,10 @@ def _enable_certificate(domain, new_cert_folder):
logger.debug("Restarting services...")
for service in ("dovecot", "metronome"):
- # Ugly trick to not restart metronome if it's not installed
- if (
- service == "metronome"
- and os.system("dpkg --list | grep -q 'ii *metronome'") != 0
+ # Ugly trick to not restart metronome if it's not installed or no domain configured for XMPP
+ if service == "metronome" and (
+ os.system("dpkg --list | grep -q 'ii *metronome'") != 0
+ or not glob("/etc/metronome/conf.d/*.cfg.lua")
):
continue
_run_service_command("restart", service)
@@ -777,7 +767,6 @@ def _backup_current_cert(domain):
def _check_domain_is_ready_for_ACME(domain):
-
from yunohost.domain import _get_parent_domain_of
from yunohost.dns import _get_dns_zone_for_domain
from yunohost.utils.dns import is_yunohost_dyndns_domain
@@ -864,9 +853,8 @@ def _regen_dnsmasq_if_needed():
do_regen = False
# For all domain files in DNSmasq conf...
- domainsconf = glob.glob("/etc/dnsmasq.d/*.*")
+ domainsconf = glob("/etc/dnsmasq.d/*.*")
for domainconf in domainsconf:
-
# Look for the IP, it's in the lines with this format :
# host-record=the.domain.tld,11.22.33.44
for line in open(domainconf).readlines():
diff --git a/src/diagnosers/00-basesystem.py b/src/diagnosers/00-basesystem.py
index 5793a00aa..336271bd1 100644
--- a/src/diagnosers/00-basesystem.py
+++ b/src/diagnosers/00-basesystem.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -35,13 +35,11 @@ logger = log.getActionLogger("yunohost.diagnosis")
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = []
def run(self):
-
virt = system_virt()
if virt.lower() == "none":
virt = "bare-metal"
@@ -193,7 +191,6 @@ class MyDiagnoser(Diagnoser):
)
def bad_sury_packages(self):
-
packages_to_check = ["openssl", "libssl1.1", "libssl-dev"]
for package in packages_to_check:
cmd = "dpkg --list | grep '^ii' | grep gbp | grep -q -w %s" % package
@@ -209,12 +206,10 @@ class MyDiagnoser(Diagnoser):
yield (package, version_to_downgrade_to)
def backports_in_sources_list(self):
-
cmd = "grep -q -nr '^ *deb .*-backports' /etc/apt/sources.list*"
return os.system(cmd) == 0
def number_of_recent_auth_failure(self):
-
# Those syslog facilities correspond to auth and authpriv
# c.f. https://unix.stackexchange.com/a/401398
# and https://wiki.archlinux.org/title/Systemd/Journal#Facility
diff --git a/src/diagnosers/10-ip.py b/src/diagnosers/10-ip.py
index b2bedc802..4f9cd9708 100644
--- a/src/diagnosers/10-ip.py
+++ b/src/diagnosers/10-ip.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -28,18 +28,17 @@ from moulinette.utils.filesystem import read_file
from yunohost.diagnosis import Diagnoser
from yunohost.utils.network import get_network_interfaces
+from yunohost.settings import settings_get
logger = log.getActionLogger("yunohost.diagnosis")
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = []
def run(self):
-
# ############################################################ #
# PING : Check that we can ping outside at least in ipv4 or v6 #
# ############################################################ #
@@ -118,10 +117,17 @@ class MyDiagnoser(Diagnoser):
else:
return local_ip
+ def is_ipvx_important(x):
+ return settings_get("misc.network.dns_exposure") in ["both", "ipv" + str(x)]
+
yield dict(
meta={"test": "ipv4"},
data={"global": ipv4, "local": get_local_ip("ipv4")},
- status="SUCCESS" if ipv4 else "ERROR",
+ status="SUCCESS"
+ if ipv4
+ else "ERROR"
+ if is_ipvx_important(4)
+ else "WARNING",
summary="diagnosis_ip_connected_ipv4" if ipv4 else "diagnosis_ip_no_ipv4",
details=["diagnosis_ip_global", "diagnosis_ip_local"] if ipv4 else None,
)
@@ -129,17 +135,24 @@ class MyDiagnoser(Diagnoser):
yield dict(
meta={"test": "ipv6"},
data={"global": ipv6, "local": get_local_ip("ipv6")},
- status="SUCCESS" if ipv6 else "WARNING",
+ status="SUCCESS"
+ if ipv6
+ else "ERROR"
+ if settings_get("misc.network.dns_exposure") == "ipv6"
+ else "WARNING",
summary="diagnosis_ip_connected_ipv6" if ipv6 else "diagnosis_ip_no_ipv6",
details=["diagnosis_ip_global", "diagnosis_ip_local"]
if ipv6
- else ["diagnosis_ip_no_ipv6_tip"],
+ else [
+ "diagnosis_ip_no_ipv6_tip_important"
+ if is_ipvx_important(6)
+ else "diagnosis_ip_no_ipv6_tip"
+ ],
)
# TODO / FIXME : add some attempt to detect ISP (using whois ?) ?
def can_ping_outside(self, protocol=4):
-
assert protocol in [
4,
6,
@@ -218,7 +231,6 @@ class MyDiagnoser(Diagnoser):
return len(content) == 1 and content[0].split() == ["nameserver", "127.0.0.1"]
def get_public_ip(self, protocol=4):
-
# FIXME - TODO : here we assume that DNS resolution for ip.yunohost.org is working
# but if we want to be able to diagnose DNS resolution issues independently from
# internet connectivity, we gotta rely on fixed IPs first....
diff --git a/src/diagnosers/12-dnsrecords.py b/src/diagnosers/12-dnsrecords.py
index 92d795ea9..2d46f979c 100644
--- a/src/diagnosers/12-dnsrecords.py
+++ b/src/diagnosers/12-dnsrecords.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -43,13 +43,11 @@ logger = log.getActionLogger("yunohost.diagnosis")
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = ["ip"]
def run(self):
-
main_domain = _get_maindomain()
major_domains = domain_list(exclude_subdomains=True)["domains"]
@@ -77,7 +75,6 @@ class MyDiagnoser(Diagnoser):
yield report
def check_domain(self, domain, is_main_domain):
-
if is_special_use_tld(domain):
yield dict(
meta={"domain": domain},
@@ -97,13 +94,11 @@ class MyDiagnoser(Diagnoser):
categories = ["basic", "mail", "xmpp", "extra"]
for category in categories:
-
records = expected_configuration[category]
discrepancies = []
results = {}
for r in records:
-
id_ = r["type"] + ":" + r["name"]
fqdn = r["name"] + "." + base_dns_zone if r["name"] != "@" else domain
@@ -182,7 +177,6 @@ class MyDiagnoser(Diagnoser):
yield output
def get_current_record(self, fqdn, type_):
-
success, answers = dig(fqdn, type_, resolvers="force_external")
if success != "ok":
diff --git a/src/diagnosers/14-ports.py b/src/diagnosers/14-ports.py
index 5671211b5..34c512f14 100644
--- a/src/diagnosers/14-ports.py
+++ b/src/diagnosers/14-ports.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -21,16 +21,15 @@ from typing import List
from yunohost.diagnosis import Diagnoser
from yunohost.service import _get_services
+from yunohost.settings import settings_get
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = ["ip"]
def run(self):
-
# TODO: report a warning if port 53 or 5353 is exposed to the outside world...
# This dict is something like :
@@ -46,7 +45,10 @@ class MyDiagnoser(Diagnoser):
ipversions = []
ipv4 = Diagnoser.get_cached_report("ip", item={"test": "ipv4"}) or {}
- if ipv4.get("status") == "SUCCESS":
+ if (
+ ipv4.get("status") == "SUCCESS"
+ or settings_get("misc.network.dns_exposure") != "ipv6"
+ ):
ipversions.append(4)
# To be discussed: we could also make this check dependent on the
@@ -120,7 +122,10 @@ class MyDiagnoser(Diagnoser):
for record in dnsrecords.get("items", [])
)
- if failed == 4 or ipv6_is_important():
+ if (
+ failed == 4
+ and settings_get("misc.network.dns_exposure") in ["both", "ipv4"]
+ ) or (failed == 6 and ipv6_is_important()):
yield dict(
meta={"port": port},
data={
diff --git a/src/diagnosers/21-web.py b/src/diagnosers/21-web.py
index 4a69895b2..2050cd658 100644
--- a/src/diagnosers/21-web.py
+++ b/src/diagnosers/21-web.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -26,22 +26,20 @@ from moulinette.utils.filesystem import read_file, mkdir, rm
from yunohost.diagnosis import Diagnoser
from yunohost.domain import domain_list
from yunohost.utils.dns import is_special_use_tld
+from yunohost.settings import settings_get
DIAGNOSIS_SERVER = "diagnosis.yunohost.org"
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = ["ip"]
def run(self):
-
all_domains = domain_list()["domains"]
domains_to_check = []
for domain in all_domains:
-
# If the diagnosis location ain't defined, can't do diagnosis,
# probably because nginx conf manually modified...
nginx_conf = "/etc/nginx/conf.d/%s.conf" % domain
@@ -76,7 +74,9 @@ class MyDiagnoser(Diagnoser):
ipversions = []
ipv4 = Diagnoser.get_cached_report("ip", item={"test": "ipv4"}) or {}
- if ipv4.get("status") == "SUCCESS":
+ if ipv4.get("status") == "SUCCESS" and settings_get(
+ "misc.network.dns_exposure"
+ ) in ["both", "ipv4"]:
ipversions.append(4)
# To be discussed: we could also make this check dependent on the
@@ -96,7 +96,10 @@ class MyDiagnoser(Diagnoser):
# "curl --head the.global.ip" will simply timeout...
if self.do_hairpinning_test:
global_ipv4 = ipv4.get("data", {}).get("global", None)
- if global_ipv4:
+ if global_ipv4 and settings_get("misc.network.dns_exposure") in [
+ "both",
+ "ipv4",
+ ]:
try:
requests.head("http://" + global_ipv4, timeout=5)
except requests.exceptions.Timeout:
@@ -113,7 +116,6 @@ class MyDiagnoser(Diagnoser):
pass
def test_http(self, domains, ipversions):
-
results = {}
for ipversion in ipversions:
try:
@@ -138,7 +140,6 @@ class MyDiagnoser(Diagnoser):
return
for domain in domains:
-
# i18n: diagnosis_http_bad_status_code
# i18n: diagnosis_http_connection_error
# i18n: diagnosis_http_timeout
@@ -147,7 +148,10 @@ class MyDiagnoser(Diagnoser):
if all(
results[ipversion][domain]["status"] == "ok" for ipversion in ipversions
):
- if 4 in ipversions:
+ if 4 in ipversions and settings_get("misc.network.dns_exposure") in [
+ "both",
+ "ipv4",
+ ]:
self.do_hairpinning_test = True
yield dict(
meta={"domain": domain},
@@ -185,7 +189,9 @@ class MyDiagnoser(Diagnoser):
)
AAAA_status = dnsrecords.get("data", {}).get("AAAA:@")
- return AAAA_status in ["OK", "WRONG"]
+ return AAAA_status in ["OK", "WRONG"] or settings_get(
+ "misc.network.dns_exposure"
+ ) in ["both", "ipv6"]
if failed == 4 or ipv6_is_important_for_this_domain():
yield dict(
diff --git a/src/diagnosers/24-mail.py b/src/diagnosers/24-mail.py
index 88d6a8259..df14222a5 100644
--- a/src/diagnosers/24-mail.py
+++ b/src/diagnosers/24-mail.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -38,13 +38,11 @@ logger = log.getActionLogger("yunohost.diagnosis")
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 600
dependencies: List[str] = ["ip"]
def run(self):
-
self.ehlo_domain = _get_maindomain()
self.mail_domains = domain_list()["domains"]
self.ipversions, self.ips = self.get_ips_checked()
@@ -279,7 +277,7 @@ class MyDiagnoser(Diagnoser):
data={"error": str(e)},
status="ERROR",
summary="diagnosis_mail_queue_unavailable",
- details="diagnosis_mail_queue_unavailable_details",
+ details=["diagnosis_mail_queue_unavailable_details"],
)
else:
if pending_emails > 100:
@@ -301,13 +299,17 @@ class MyDiagnoser(Diagnoser):
outgoing_ipversions = []
outgoing_ips = []
ipv4 = Diagnoser.get_cached_report("ip", {"test": "ipv4"}) or {}
- if ipv4.get("status") == "SUCCESS":
+ if ipv4.get("status") == "SUCCESS" and settings_get(
+ "misc.network.dns_exposure"
+ ) in ["both", "ipv4"]:
outgoing_ipversions.append(4)
global_ipv4 = ipv4.get("data", {}).get("global", {})
if global_ipv4:
outgoing_ips.append(global_ipv4)
- if settings_get("email.smtp.smtp_allow_ipv6"):
+ if settings_get("email.smtp.smtp_allow_ipv6") or settings_get(
+ "misc.network.dns_exposure"
+ ) in ["both", "ipv6"]:
ipv6 = Diagnoser.get_cached_report("ip", {"test": "ipv6"}) or {}
if ipv6.get("status") == "SUCCESS":
outgoing_ipversions.append(6)
diff --git a/src/diagnosers/30-services.py b/src/diagnosers/30-services.py
index 7adfd7c01..42ea9d18f 100644
--- a/src/diagnosers/30-services.py
+++ b/src/diagnosers/30-services.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -24,17 +24,14 @@ from yunohost.service import service_status
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 300
dependencies: List[str] = []
def run(self):
-
all_result = service_status()
for service, result in sorted(all_result.items()):
-
item = dict(
meta={"service": service},
data={
diff --git a/src/diagnosers/50-systemresources.py b/src/diagnosers/50-systemresources.py
index 50933b9f9..096c3483f 100644
--- a/src/diagnosers/50-systemresources.py
+++ b/src/diagnosers/50-systemresources.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -28,13 +28,11 @@ from yunohost.diagnosis import Diagnoser
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 300
dependencies: List[str] = []
def run(self):
-
MB = 1024**2
GB = MB * 1024
@@ -189,7 +187,6 @@ class MyDiagnoser(Diagnoser):
return []
def analyzed_kern_log():
-
cmd = 'tail -n 10000 /var/log/kern.log | grep "oom_reaper: reaped process" || true'
out = check_output(cmd)
lines = out.split("\n") if out else []
diff --git a/src/diagnosers/70-regenconf.py b/src/diagnosers/70-regenconf.py
index 8c0bf74cc..65195aac5 100644
--- a/src/diagnosers/70-regenconf.py
+++ b/src/diagnosers/70-regenconf.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -27,13 +27,11 @@ from moulinette.utils.filesystem import read_file
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 300
dependencies: List[str] = []
def run(self):
-
regenconf_modified_files = list(self.manually_modified_files())
if not regenconf_modified_files:
@@ -82,7 +80,6 @@ class MyDiagnoser(Diagnoser):
)
def manually_modified_files(self):
-
for category, infos in _get_regenconf_infos().items():
for path, hash_ in infos["conffiles"].items():
if hash_ != _calculate_hash(path):
diff --git a/src/diagnosers/80-apps.py b/src/diagnosers/80-apps.py
index faff925e6..44ce86bcc 100644
--- a/src/diagnosers/80-apps.py
+++ b/src/diagnosers/80-apps.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -25,13 +25,11 @@ from yunohost.diagnosis import Diagnoser
class MyDiagnoser(Diagnoser):
-
id_ = os.path.splitext(os.path.basename(__file__))[0].split("-")[1]
cache_duration = 300
dependencies: List[str] = []
def run(self):
-
apps = app_list(full=True)["apps"]
for app in apps:
app["issues"] = list(self.issues(app))
@@ -44,7 +42,6 @@ class MyDiagnoser(Diagnoser):
)
else:
for app in apps:
-
if not app["issues"]:
continue
@@ -62,7 +59,6 @@ class MyDiagnoser(Diagnoser):
)
def issues(self, app):
-
# Check quality level in catalog
if not app.get("from_catalog") or app["from_catalog"].get("state") != "working":
diff --git a/src/diagnosers/__init__.py b/src/diagnosers/__init__.py
index 5cad500fa..7c1e7b0cd 100644
--- a/src/diagnosers/__init__.py
+++ b/src/diagnosers/__init__.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
diff --git a/src/diagnosis.py b/src/diagnosis.py
index 2dff6a40d..02047c001 100644
--- a/src/diagnosis.py
+++ b/src/diagnosis.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -45,7 +45,6 @@ def diagnosis_list():
def diagnosis_get(category, item):
-
# Get all the categories
all_categories_names = _list_diagnosis_categories()
@@ -69,7 +68,6 @@ def diagnosis_get(category, item):
def diagnosis_show(
categories=[], issues=False, full=False, share=False, human_readable=False
):
-
if not os.path.exists(DIAGNOSIS_CACHE):
logger.warning(m18n.n("diagnosis_never_ran_yet"))
return
@@ -90,7 +88,6 @@ def diagnosis_show(
# Fetch all reports
all_reports = []
for category in categories:
-
try:
report = Diagnoser.get_cached_report(category)
except Exception as e:
@@ -139,7 +136,6 @@ def diagnosis_show(
def _dump_human_readable_reports(reports):
-
output = ""
for report in reports:
@@ -159,7 +155,6 @@ def _dump_human_readable_reports(reports):
def diagnosis_run(
categories=[], force=False, except_if_never_ran_yet=False, email=False
):
-
if (email or except_if_never_ran_yet) and not os.path.exists(DIAGNOSIS_CACHE):
return
@@ -263,7 +258,6 @@ def _diagnosis_ignore(add_filter=None, remove_filter=None, list=False):
return {"ignore_filters": configuration.get("ignore_filters", {})}
def validate_filter_criterias(filter_):
-
# Get all the categories
all_categories_names = _list_diagnosis_categories()
@@ -286,7 +280,6 @@ def _diagnosis_ignore(add_filter=None, remove_filter=None, list=False):
return category, criterias
if add_filter:
-
category, criterias = validate_filter_criterias(add_filter)
# Fetch current issues for the requested category
@@ -320,7 +313,6 @@ def _diagnosis_ignore(add_filter=None, remove_filter=None, list=False):
return
if remove_filter:
-
category, criterias = validate_filter_criterias(remove_filter)
# Make sure the subdicts/lists exists
@@ -394,12 +386,10 @@ def add_ignore_flag_to_issues(report):
class Diagnoser:
def __init__(self):
-
self.cache_file = Diagnoser.cache_file(self.id_)
self.description = Diagnoser.get_description(self.id_)
def cached_time_ago(self):
-
if not os.path.exists(self.cache_file):
return 99999999
return time.time() - os.path.getmtime(self.cache_file)
@@ -410,7 +400,6 @@ class Diagnoser:
return write_to_json(self.cache_file, report)
def diagnose(self, force=False):
-
if not force and self.cached_time_ago() < self.cache_duration:
logger.debug(f"Cache still valid : {self.cache_file}")
logger.info(
@@ -548,7 +537,6 @@ class Diagnoser:
@staticmethod
def i18n(report, force_remove_html_tags=False):
-
# "Render" the strings with m18n.n
# N.B. : we do those m18n.n right now instead of saving the already-translated report
# because we can't be sure we'll redisplay the infos with the same locale as it
@@ -558,7 +546,6 @@ class Diagnoser:
report["description"] = Diagnoser.get_description(report["id"])
for item in report["items"]:
-
# For the summary and each details, we want to call
# m18n() on the string, with the appropriate data for string
# formatting which can come from :
@@ -597,7 +584,6 @@ class Diagnoser:
@staticmethod
def remote_diagnosis(uri, data, ipversion, timeout=30):
-
# Lazy loading for performance
import requests
import socket
@@ -646,7 +632,6 @@ class Diagnoser:
def _list_diagnosis_categories():
-
paths = glob.glob(os.path.dirname(__file__) + "/diagnosers/??-*.py")
names = [
name.split("-")[-1]
@@ -657,7 +642,6 @@ def _list_diagnosis_categories():
def _load_diagnoser(diagnoser_name):
-
logger.debug(f"Loading diagnoser {diagnoser_name}")
paths = glob.glob(os.path.dirname(__file__) + f"/diagnosers/??-{diagnoser_name}.py")
diff --git a/src/dns.py b/src/dns.py
index 296ecfaaa..d514d1b17 100644
--- a/src/dns.py
+++ b/src/dns.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -38,6 +38,7 @@ from yunohost.domain import (
from yunohost.utils.dns import dig, is_yunohost_dyndns_domain, is_special_use_tld
from yunohost.utils.error import YunohostValidationError, YunohostError
from yunohost.utils.network import get_public_ip
+from yunohost.settings import settings_get
from yunohost.log import is_unit_operation
from yunohost.hook import hook_callback
@@ -137,7 +138,7 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
{"type": "A", "name": "*", "value": "123.123.123.123", "ttl": 3600},
# if ipv6 available
{"type": "AAAA", "name": "*", "value": "valid-ipv6", "ttl": 3600},
- {"type": "CAA", "name": "@", "value": "128 issue \"letsencrypt.org\"", "ttl": 3600},
+ {"type": "CAA", "name": "@", "value": "0 issue \"letsencrypt.org\"", "ttl": 3600},
],
"example_of_a_custom_rule": [
{"type": "SRV", "name": "_matrix", "value": "domain.tld.", "ttl": 3600}
@@ -168,7 +169,6 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
base_dns_zone = _get_dns_zone_for_domain(base_domain)
for domain, settings in domains_settings.items():
-
# Domain # Base DNS zone # Basename # Suffix #
# ------------------ # ----------------- # --------- # -------- #
# domain.tld # domain.tld # @ # #
@@ -185,7 +185,7 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
###########################
# Basic ipv4/ipv6 records #
###########################
- if ipv4:
+ if ipv4 and settings_get("misc.network.dns_exposure") in ["both", "ipv4"]:
basic.append([basename, ttl, "A", ipv4])
if ipv6:
@@ -240,7 +240,7 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
# Only recommend wildcard and CAA for the top level
if domain == base_domain:
- if ipv4:
+ if ipv4 and settings_get("misc.network.dns_exposure") in ["both", "ipv4"]:
extra.append([f"*{suffix}", ttl, "A", ipv4])
if ipv6:
@@ -248,7 +248,7 @@ def _build_dns_conf(base_domain, include_empty_AAAA_if_no_ipv6=False):
elif include_empty_AAAA_if_no_ipv6:
extra.append([f"*{suffix}", ttl, "AAAA", None])
- extra.append([basename, ttl, "CAA", '128 issue "letsencrypt.org"'])
+ extra.append([basename, ttl, "CAA", '0 issue "letsencrypt.org"'])
####################
# Standard records #
@@ -461,7 +461,6 @@ def _get_dns_zone_for_domain(domain):
# We don't wan't to do A NS request on the tld
for parent in parent_list[0:-1]:
-
# Check if there's a NS record for that domain
answer = dig(parent, rdtype="NS", full_answers=True, resolvers="force_external")
@@ -502,7 +501,6 @@ def _get_relative_name_for_dns_zone(domain, base_dns_zone):
def _get_registrar_config_section(domain):
-
from lexicon.providers.auto import _relevant_provider_for_domain
registrar_infos = {
@@ -516,7 +514,6 @@ def _get_registrar_config_section(domain):
# If parent domain exists in yunohost
parent_domain = _get_parent_domain_of(domain, topest=True)
if parent_domain:
-
# Dirty hack to have a link on the webadmin
if Moulinette.interface.type == "api":
parent_domain_link = f"[{parent_domain}](#/domains/{parent_domain}/dns)"
@@ -571,7 +568,6 @@ def _get_registrar_config_section(domain):
}
)
else:
-
registrar_infos["registrar"] = OrderedDict(
{
"type": "alert",
@@ -595,7 +591,12 @@ def _get_registrar_config_section(domain):
# TODO : add a help tip with the link to the registar's API doc (c.f. Lexicon's README)
registrar_list = read_toml(DOMAIN_REGISTRAR_LIST_PATH)
- registrar_credentials = registrar_list[registrar]
+ registrar_credentials = registrar_list.get(registrar)
+ if registrar_credentials is None:
+ logger.warning(
+ f"Registrar {registrar} unknown / Should be added to YunoHost's registrar_list.toml by the development team!"
+ )
+ registrar_credentials = {}
for credential, infos in registrar_credentials.items():
infos["default"] = infos.get("default", "")
infos["optional"] = infos.get("optional", "False")
@@ -605,7 +606,6 @@ def _get_registrar_config_section(domain):
def _get_registar_settings(domain):
-
_assert_domain_exists(domain)
settings = domain_config_get(domain, key="dns.registrar", export=True)
@@ -679,7 +679,6 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
wanted_records = []
for records in _build_dns_conf(domain).values():
for record in records:
-
# Make sure the name is a FQDN
name = (
f"{record['name']}.{base_dns_zone}"
@@ -754,7 +753,6 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
]
for record in current_records:
-
# Try to get rid of weird stuff like ".domain.tld" or "@.domain.tld"
record["name"] = record["name"].strip("@").strip(".")
@@ -804,7 +802,6 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
comparison[(record["type"], record["name"])]["wanted"].append(record)
for type_and_name, records in comparison.items():
-
#
# Step 1 : compute a first "diff" where we remove records which are the same on both sides
#
@@ -948,9 +945,7 @@ def domain_dns_push(operation_logger, domain, dry_run=False, force=False, purge=
results = {"warnings": [], "errors": []}
for action in ["delete", "create", "update"]:
-
for record in changes[action]:
-
relative_name = _get_relative_name_for_dns_zone(
record["name"], base_dns_zone
)
@@ -975,6 +970,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 = (
@@ -1035,7 +1033,6 @@ def _set_managed_dns_records_hashes(domain: str, hashes: list) -> None:
def _hash_dns_record(record: dict) -> int:
-
fields = ["name", "type", "content"]
record_ = {f: record.get(f) for f in fields}
diff --git a/src/domain.py b/src/domain.py
index 9dc884177..35730483b 100644
--- a/src/domain.py
+++ b/src/domain.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -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.utils.dns import is_yunohost_dyndns_domain
from yunohost.log import is_unit_operation
@@ -188,7 +189,6 @@ def _assert_domain_exists(domain):
def _list_subdomains_of(parent_domain):
-
_assert_domain_exists(parent_domain)
out = []
@@ -200,7 +200,6 @@ def _list_subdomains_of(parent_domain):
def _get_parent_domain_of(domain, return_self=False, topest=False):
-
domains = _get_domains(exclude_subdomains=topest)
domain_ = domain
@@ -657,7 +656,6 @@ class DomainConfigPanel(ConfigPanel):
regen_conf(names=stuff_to_regen_conf)
def _get_toml(self):
-
toml = super()._get_toml()
toml["feature"]["xmpp"]["xmpp"]["default"] = (
@@ -679,7 +677,6 @@ class DomainConfigPanel(ConfigPanel):
# Cert stuff
if not filter_key or filter_key[0] == "cert":
-
from yunohost.certificate import certificate_status
status = certificate_status([self.entity], full=True)["certificates"][
@@ -697,16 +694,29 @@ class DomainConfigPanel(ConfigPanel):
f"domain_config_cert_summary_{status['summary']}"
)
- # Other specific strings used in config panels
- # i18n: domain_config_cert_renew_help
-
# FIXME: Ugly hack to save the cert status and reinject it in _load_current_values ...
self.cert_status = status
return toml
- def _load_current_values(self):
+ def get(self, key="", mode="classic"):
+ result = super().get(key=key, mode=mode)
+ if mode == "full":
+ for panel, section, option in self._iterate():
+ # This injects:
+ # i18n: domain_config_cert_renew_help
+ # i18n: domain_config_default_app_help
+ # i18n: domain_config_xmpp_help
+ if m18n.key_exists(self.config["i18n"] + "_" + option["id"] + "_help"):
+ option["help"] = m18n.n(
+ self.config["i18n"] + "_" + option["id"] + "_help"
+ )
+ return self.config
+
+ return result
+
+ def _load_current_values(self):
# TODO add mechanism to share some settings with other domains on the same zone
super()._load_current_values()
@@ -724,7 +734,6 @@ class DomainConfigPanel(ConfigPanel):
def domain_action_run(domain, action, args=None):
-
import urllib.parse
if action == "cert.cert.cert_install":
@@ -739,7 +748,6 @@ def domain_action_run(domain, action, args=None):
def _get_domain_settings(domain: str) -> dict:
-
_assert_domain_exists(domain)
if os.path.exists(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml"):
@@ -749,7 +757,6 @@ def _get_domain_settings(domain: str) -> dict:
def _set_domain_settings(domain: str, settings: dict) -> None:
-
_assert_domain_exists(domain)
write_to_yaml(f"{DOMAIN_SETTINGS_DIR}/{domain}.yml", settings)
diff --git a/src/dyndns.py b/src/dyndns.py
index 2f83038cc..3c9788af7 100644
--- a/src/dyndns.py
+++ b/src/dyndns.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -329,7 +329,6 @@ def dyndns_update(
for dns_auth in DYNDNS_DNS_AUTH:
for type_ in ["A", "AAAA"]:
-
ok, result = dig(dns_auth, type_)
if ok == "ok" and len(result) and result[0]:
auth_resolvers.append(result[0])
@@ -340,7 +339,6 @@ def dyndns_update(
)
def resolve_domain(domain, rdtype):
-
ok, result = dig(domain, rdtype, resolvers=auth_resolvers)
if ok == "ok":
return result[0] if len(result) else None
diff --git a/src/firewall.py b/src/firewall.py
index 6cf68f1f7..310d263c6 100644
--- a/src/firewall.py
+++ b/src/firewall.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -101,7 +101,9 @@ def firewall_allow(
# Update and reload firewall
_update_firewall_file(firewall)
- if not no_reload or (reload_only_if_change and changed):
+ if (not reload_only_if_change and not no_reload) or (
+ reload_only_if_change and changed
+ ):
return firewall_reload()
@@ -180,7 +182,9 @@ def firewall_disallow(
# Update and reload firewall
_update_firewall_file(firewall)
- if not no_reload or (reload_only_if_change and changed):
+ if (not reload_only_if_change and not no_reload) or (
+ reload_only_if_change and changed
+ ):
return firewall_reload()
@@ -415,7 +419,6 @@ def firewall_upnp(action="status", no_refresh=False):
for protocol in ["TCP", "UDP"]:
if protocol + "_TO_CLOSE" in firewall["uPnP"]:
for port in firewall["uPnP"][protocol + "_TO_CLOSE"]:
-
if not isinstance(port, int):
# FIXME : how should we handle port ranges ?
logger.warning("Can't use UPnP to close '%s'" % port)
@@ -430,7 +433,6 @@ def firewall_upnp(action="status", no_refresh=False):
firewall["uPnP"][protocol + "_TO_CLOSE"] = []
for port in firewall["uPnP"][protocol]:
-
if not isinstance(port, int):
# FIXME : how should we handle port ranges ?
logger.warning("Can't use UPnP to open '%s'" % port)
diff --git a/src/hook.py b/src/hook.py
index d985f5184..7f4cc28d4 100644
--- a/src/hook.py
+++ b/src/hook.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -339,7 +339,6 @@ def hook_exec(
raise YunohostError("file_does_not_exist", path=path)
def is_relevant_warning(msg):
-
# Ignore empty warning messages...
if not msg:
return False
@@ -353,6 +352,31 @@ def hook_exec(
r"dpkg: warning: while removing .* not empty so not removed",
r"apt-key output should not be parsed",
r"update-rc.d: ",
+ r"update-alternatives: ",
+ # Postgresql boring messages -_-
+ r"Adding user postgres to group ssl-cert",
+ r"Building PostgreSQL dictionaries from .*",
+ r"Removing obsolete dictionary files",
+ r"Creating new PostgreSQL cluster",
+ r"/usr/lib/postgresql/13/bin/initdb",
+ r"The files belonging to this database system will be owned by user",
+ r"This user must also own the server process.",
+ r"The database cluster will be initialized with locale",
+ r"The default database encoding has accordingly been set to",
+ r"The default text search configuration will be set to",
+ r"Data page checksums are disabled.",
+ r"fixing permissions on existing directory /var/lib/postgresql/13/main ... ok",
+ r"creating subdirectories \.\.\. ok",
+ r"selecting dynamic .* \.\.\. ",
+ r"selecting default .* \.\.\. ",
+ r"creating configuration files \.\.\. ok",
+ r"running bootstrap script \.\.\. ok",
+ r"performing post-bootstrap initialization \.\.\. ok",
+ r"syncing data to disk \.\.\. ok",
+ r"Success. You can now start the database server using:",
+ r"pg_ctlcluster \d\d main start",
+ r"Ver\s*Cluster\s*Port\s*Status\s*Owner\s*Data\s*directory",
+ r"/var/lib/postgresql/\d\d/main /var/log/postgresql/postgresql-\d\d-main.log",
]
return all(not re.search(w, msg) for w in irrelevant_warnings)
@@ -389,7 +413,6 @@ def hook_exec(
def _hook_exec_bash(path, args, chdir, env, user, return_format, loggers):
-
from moulinette.utils.process import call_async_output
# Construct command variables
@@ -477,7 +500,6 @@ def _hook_exec_bash(path, args, chdir, env, user, return_format, loggers):
def _hook_exec_python(path, args, env, loggers):
-
dir_ = os.path.dirname(path)
name = os.path.splitext(os.path.basename(path))[0]
@@ -497,7 +519,6 @@ def _hook_exec_python(path, args, env, loggers):
def hook_exec_with_script_debug_if_failure(*args, **kwargs):
-
operation_logger = kwargs.pop("operation_logger")
error_message_if_failed = kwargs.pop("error_message_if_failed")
error_message_if_script_failed = kwargs.pop("error_message_if_script_failed")
diff --git a/src/log.py b/src/log.py
index 6525b904d..5ab918e76 100644
--- a/src/log.py
+++ b/src/log.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -16,6 +16,7 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
#
+import copy
import os
import re
import yaml
@@ -95,7 +96,6 @@ def log_list(limit=None, with_details=False, with_suboperations=False):
logs = logs[: limit * 5]
for log in logs:
-
base_filename = log[: -len(METADATA_FILE_EXT)]
md_path = os.path.join(OPERATIONS_PATH, log)
@@ -264,7 +264,6 @@ def log_show(
return
for filename in os.listdir(OPERATIONS_PATH):
-
if not filename.endswith(METADATA_FILE_EXT):
continue
@@ -438,7 +437,6 @@ class RedactingFormatter(Formatter):
return msg
def identify_data_to_redact(self, record):
-
# Wrapping this in a try/except because we don't want this to
# break everything in case it fails miserably for some reason :s
try:
@@ -497,7 +495,6 @@ class OperationLogger:
os.makedirs(self.path)
def parent_logger(self):
-
# If there are other operation logger instances
for instance in reversed(self._instances):
# Is one of these operation logger started but not yet done ?
@@ -598,7 +595,17 @@ class OperationLogger:
Write or rewrite the metadata file with all metadata known
"""
- dump = yaml.safe_dump(self.metadata, default_flow_style=False)
+ metadata = copy.copy(self.metadata)
+
+ # Remove lower-case keys ... this is because with the new v2 app packaging,
+ # all settings are included in the env but we probably don't want to dump all of these
+ # which may contain various secret/private data ...
+ if "env" in metadata:
+ metadata["env"] = {
+ k: v for k, v in metadata["env"].items() if k == k.upper()
+ }
+
+ dump = yaml.safe_dump(metadata, default_flow_style=False)
for data in self.data_to_redact:
# N.B. : we need quotes here, otherwise yaml isn't happy about loading the yml later
dump = dump.replace(data, "'**********'")
@@ -732,7 +739,6 @@ class OperationLogger:
self.error(m18n.n("log_operation_unit_unclosed_properly"))
def dump_script_log_extract_for_debugging(self):
-
with open(self.log_path, "r") as f:
lines = f.readlines()
@@ -774,7 +780,6 @@ class OperationLogger:
def _get_datetime_from_name(name):
-
# Filenames are expected to follow the format:
# 20200831-170740-short_description-and-stuff
diff --git a/src/migrations/0021_migrate_to_bullseye.py b/src/migrations/0021_migrate_to_bullseye.py
index 54917cf95..f320577e1 100644
--- a/src/migrations/0021_migrate_to_bullseye.py
+++ b/src/migrations/0021_migrate_to_bullseye.py
@@ -72,13 +72,11 @@ def _backup_pip_freeze_for_python_app_venvs():
class MyMigration(Migration):
-
"Upgrade the system to Debian Bullseye and Yunohost 11.x"
mode = "manual"
def run(self):
-
self.check_assertions()
logger.info(m18n.n("migration_0021_start"))
@@ -389,7 +387,6 @@ class MyMigration(Migration):
return int(get_ynh_package_version("yunohost")["version"].split(".")[0])
def check_assertions(self):
-
# Be on buster (10.x) and yunohost 4.x
# NB : we do both check to cover situations where the upgrade crashed
# in the middle and debian version could be > 9.x but yunohost package
@@ -453,7 +450,6 @@ class MyMigration(Migration):
@property
def disclaimer(self):
-
# Avoid having a super long disclaimer + uncessary check if we ain't
# on buster / yunohost 4.x anymore
# NB : we do both check to cover situations where the upgrade crashed
@@ -494,7 +490,6 @@ class MyMigration(Migration):
return message
def patch_apt_sources_list(self):
-
sources_list = glob.glob("/etc/apt/sources.list.d/*.list")
if os.path.exists("/etc/apt/sources.list"):
sources_list.append("/etc/apt/sources.list")
@@ -516,7 +511,6 @@ class MyMigration(Migration):
os.system(command)
def get_apps_equivs_packages(self):
-
command = (
"dpkg --get-selections"
" | grep -v deinstall"
diff --git a/src/migrations/0022_php73_to_php74_pools.py b/src/migrations/0022_php73_to_php74_pools.py
index a2e5eae54..dc428e504 100644
--- a/src/migrations/0022_php73_to_php74_pools.py
+++ b/src/migrations/0022_php73_to_php74_pools.py
@@ -27,7 +27,6 @@ MIGRATION_COMMENT = (
class MyMigration(Migration):
-
"Migrate php7.3-fpm 'pool' conf files to php7.4"
dependencies = ["migrate_to_bullseye"]
@@ -43,7 +42,6 @@ class MyMigration(Migration):
oldphp_pool_files = [f for f in oldphp_pool_files if f != "www.conf"]
for pf in oldphp_pool_files:
-
# Copy the files to the php7.3 pool
src = "{}/{}".format(OLDPHP_POOLS, pf)
dest = "{}/{}".format(NEWPHP_POOLS, pf)
diff --git a/src/migrations/0023_postgresql_11_to_13.py b/src/migrations/0023_postgresql_11_to_13.py
index f0128da0b..6d37ffa74 100644
--- a/src/migrations/0023_postgresql_11_to_13.py
+++ b/src/migrations/0023_postgresql_11_to_13.py
@@ -13,13 +13,11 @@ logger = getActionLogger("yunohost.migration")
class MyMigration(Migration):
-
"Migrate DBs from Postgresql 11 to 13 after migrating to Bullseye"
dependencies = ["migrate_to_bullseye"]
def run(self):
-
if (
os.system(
'grep -A10 "ynh-deps" /var/lib/dpkg/status | grep -E "Package:|Depends:" | grep -B1 postgresql'
@@ -63,7 +61,6 @@ class MyMigration(Migration):
self.runcmd("systemctl start postgresql")
def package_is_installed(self, package_name):
-
(returncode, out, err) = self.runcmd(
"dpkg --list | grep '^ii ' | grep -q -w {}".format(package_name),
raise_on_errors=False,
@@ -71,7 +68,6 @@ class MyMigration(Migration):
return returncode == 0
def runcmd(self, cmd, raise_on_errors=True):
-
logger.debug("Running command: " + cmd)
p = subprocess.Popen(
diff --git a/src/migrations/0024_rebuild_python_venv.py b/src/migrations/0024_rebuild_python_venv.py
index d5aa7fc10..01a229b87 100644
--- a/src/migrations/0024_rebuild_python_venv.py
+++ b/src/migrations/0024_rebuild_python_venv.py
@@ -14,7 +14,6 @@ VENV_REQUIREMENTS_SUFFIX = ".requirements_backup_for_bullseye_upgrade.txt"
def extract_app_from_venv_path(venv_path):
-
venv_path = venv_path.replace("/var/www/", "")
venv_path = venv_path.replace("/opt/yunohost/", "")
venv_path = venv_path.replace("/opt/", "")
@@ -137,13 +136,11 @@ class MyMigration(Migration):
return msg
def run(self):
-
if self.mode == "auto":
return
venvs = _get_all_venvs("/opt/") + _get_all_venvs("/var/www/")
for venv in venvs:
-
app_corresponding_to_venv = extract_app_from_venv_path(venv)
# Search for ignore apps
diff --git a/src/migrations/0025_global_settings_to_configpanel.py b/src/migrations/0025_global_settings_to_configpanel.py
index 3a43ccb13..3a8818461 100644
--- a/src/migrations/0025_global_settings_to_configpanel.py
+++ b/src/migrations/0025_global_settings_to_configpanel.py
@@ -14,7 +14,6 @@ OLD_SETTINGS_PATH = "/etc/yunohost/settings.json"
class MyMigration(Migration):
-
"Migrate old global settings to the new ConfigPanel global settings"
dependencies = ["migrate_to_bullseye"]
diff --git a/src/migrations/0026_new_admins_group.py b/src/migrations/0026_new_admins_group.py
index 5d9167ae7..43f10a7b6 100644
--- a/src/migrations/0026_new_admins_group.py
+++ b/src/migrations/0026_new_admins_group.py
@@ -21,7 +21,6 @@ class MyMigration(Migration):
@Migration.ldap_migration
def run(self, *args):
-
from yunohost.user import (
user_list,
user_info,
@@ -47,6 +46,21 @@ class MyMigration(Migration):
new_admin_user = user
break
+ # For some reason some system have no user with root@ alias,
+ # but the user does has admin / postmaster / ... alias
+ # ... try to find it instead otherwise this creashes the migration
+ # later because the admin@, postmaster@, .. aliases will already exist
+ if not new_admin_user:
+ for user in all_users:
+ aliases = user_info(user).get("mail-aliases", [])
+ if any(
+ alias.startswith(f"admin@{main_domain}") for alias in aliases
+ ) or any(
+ alias.startswith(f"postmaster@{main_domain}") for alias in aliases
+ ):
+ new_admin_user = user
+ break
+
self.ldap_migration_started = True
if new_admin_user:
diff --git a/src/permission.py b/src/permission.py
index e451bb74c..72975561f 100644
--- a/src/permission.py
+++ b/src/permission.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -79,7 +79,6 @@ def user_permission_list(
permissions = {}
for infos in permissions_infos:
-
name = infos["cn"][0]
app = name.split(".")[0]
@@ -654,7 +653,6 @@ def permission_sync_to_user():
permissions = user_permission_list(full=True)["permissions"]
for permission_name, permission_infos in permissions.items():
-
# These are the users currently allowed because there's an 'inheritPermission' object corresponding to it
currently_allowed_users = set(permission_infos["corresponding_users"])
@@ -740,7 +738,6 @@ def _update_ldap_group_permission(
update["isProtected"] = [str(protected).upper()]
if show_tile is not None:
-
if show_tile is True:
if not existing_permission["url"]:
logger.warning(
@@ -817,10 +814,12 @@ def _update_ldap_group_permission(
def _get_absolute_url(url, base_path):
#
# For example transform:
- # (/api, domain.tld/nextcloud) into domain.tld/nextcloud/api
- # (/api, domain.tld/nextcloud/) into domain.tld/nextcloud/api
- # (re:/foo.*, domain.tld/app) into re:domain\.tld/app/foo.*
- # (domain.tld/bar, domain.tld/app) into domain.tld/bar
+ # (/, domain.tld/) into domain.tld (no trailing /)
+ # (/api, domain.tld/nextcloud) into domain.tld/nextcloud/api
+ # (/api, domain.tld/nextcloud/) into domain.tld/nextcloud/api
+ # (re:/foo.*, domain.tld/app) into re:domain\.tld/app/foo.*
+ # (domain.tld/bar, domain.tld/app) into domain.tld/bar
+ # (some.other.domain/, domain.tld/app) into some.other.domain (no trailing /)
#
base_path = base_path.rstrip("/")
if url is None:
@@ -830,7 +829,7 @@ def _get_absolute_url(url, base_path):
if url.startswith("re:/"):
return "re:" + base_path.replace(".", "\\.") + url[3:]
else:
- return url
+ return url.rstrip("/")
def _validate_and_sanitize_permission_url(url, app_base_path, app):
@@ -876,7 +875,6 @@ def _validate_and_sanitize_permission_url(url, app_base_path, app):
raise YunohostValidationError("invalid_regex", regex=regex)
if url.startswith("re:"):
-
# regex without domain
# we check for the first char after 're:'
if url[3] in ["/", "^", "\\"]:
diff --git a/src/regenconf.py b/src/regenconf.py
index f1163e66a..69bedb262 100644
--- a/src/regenconf.py
+++ b/src/regenconf.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -77,7 +77,6 @@ def regen_conf(
for category, conf_files in pending_conf.items():
for system_path, pending_path in conf_files.items():
-
pending_conf[category][system_path] = {
"pending_conf": pending_path,
"diff": _get_files_diff(system_path, pending_path, True),
@@ -595,7 +594,6 @@ def _update_conf_hashes(category, hashes):
def _force_clear_hashes(paths):
-
categories = _get_regenconf_infos()
for path in paths:
for category in categories.keys():
@@ -675,7 +673,6 @@ def _process_regen_conf(system_conf, new_conf=None, save=True):
def manually_modified_files():
-
output = []
regenconf_categories = _get_regenconf_infos()
for category, infos in regenconf_categories.items():
@@ -690,7 +687,6 @@ def manually_modified_files():
def manually_modified_files_compared_to_debian_default(
ignore_handled_by_regenconf=False,
):
-
# from https://serverfault.com/a/90401
files = check_output(
"dpkg-query -W -f='${Conffiles}\n' '*' \
diff --git a/src/service.py b/src/service.py
index 1f1c35c44..47bc1903a 100644
--- a/src/service.py
+++ b/src/service.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -249,12 +249,10 @@ def service_reload_or_restart(names, test_conf=True):
services = _get_services()
for name in names:
-
logger.debug(f"Reloading service {name}")
test_conf_cmd = services.get(name, {}).get("test_conf")
if test_conf and test_conf_cmd:
-
p = subprocess.Popen(
test_conf_cmd,
shell=True,
@@ -393,7 +391,6 @@ def _get_service_information_from_systemd(service):
def _get_and_format_service_status(service, infos):
-
systemd_service = infos.get("actual_systemd_service", service)
raw_status, raw_service = _get_service_information_from_systemd(systemd_service)
@@ -414,7 +411,6 @@ def _get_and_format_service_status(service, infos):
# If no description was there, try to get it from the .json locales
if not description:
-
translation_key = f"service_description_{service}"
if m18n.key_exists(translation_key):
description = m18n.n(translation_key)
@@ -521,7 +517,6 @@ def service_log(name, number=50):
result["journalctl"] = _get_journalctl_logs(name, number).splitlines()
for log_path in log_list:
-
if not os.path.exists(log_path):
continue
@@ -620,7 +615,6 @@ def _run_service_command(action, service):
def _give_lock(action, service, p):
-
# Depending of the action, systemctl calls the PID differently :/
if action == "start" or action == "restart":
systemctl_PID_name = "MainPID"
@@ -712,6 +706,10 @@ def _get_services():
"category": "web",
}
+ # Ignore metronome entirely if XMPP was disabled on all domains
+ if "metronome" in services and not glob("/etc/metronome/conf.d/*.cfg.lua"):
+ del services["metronome"]
+
# Remove legacy /var/log/daemon.log and /var/log/syslog from log entries
# because they are too general. Instead, now the journalctl log is
# returned by default which is more relevant.
@@ -740,7 +738,6 @@ def _save_services(services):
diff = {}
for service_name, service_infos in services.items():
-
# Ignore php-fpm services, they are to be added dynamically by the core,
# but not actually saved
if service_name.startswith("php") and service_name.endswith("-fpm"):
@@ -778,7 +775,7 @@ def _tail(file, n):
f = gzip.open(file)
lines = f.read().splitlines()
else:
- f = open(file)
+ f = open(file, errors="replace")
pos = 1
lines = []
while len(lines) < to_read and pos > 0:
diff --git a/src/settings.py b/src/settings.py
index d9ea600a4..5d52329b3 100644
--- a/src/settings.py
+++ b/src/settings.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -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
@@ -59,7 +60,6 @@ def settings_get(key="", full=False, export=False):
def settings_list(full=False):
-
settings = settings_get(full=full)
if full:
@@ -126,7 +126,6 @@ class SettingsConfigPanel(ConfigPanel):
super().__init__("settings")
def _apply(self):
-
root_password = self.new_values.pop("root_password", None)
root_password_confirm = self.new_values.pop("root_password_confirm", None)
passwordless_sudo = self.new_values.pop("passwordless_sudo", None)
@@ -141,7 +140,6 @@ class SettingsConfigPanel(ConfigPanel):
assert all(v not in self.future_values for v in self.virtual_settings)
if root_password and root_password.strip():
-
if root_password != root_password_confirm:
raise YunohostValidationError("password_confirmation_not_the_same")
@@ -173,7 +171,6 @@ class SettingsConfigPanel(ConfigPanel):
raise
def _get_toml(self):
-
toml = super()._get_toml()
# Dynamic choice list for portal themes
@@ -187,7 +184,6 @@ class SettingsConfigPanel(ConfigPanel):
return toml
def _load_current_values(self):
-
super()._load_current_values()
# Specific logic for those settings who are "virtual" settings
@@ -203,11 +199,10 @@ class SettingsConfigPanel(ConfigPanel):
self.values["passwordless_sudo"] = "!authenticate" in ldap.search(
"ou=sudo", "cn=admins", ["sudoOption"]
)[0].get("sudoOption", [])
- except:
+ except Exception:
self.values["passwordless_sudo"] = False
def get(self, key="", mode="classic"):
-
result = super().get(key=key, mode=mode)
if mode == "full":
@@ -353,7 +348,7 @@ def reconfigure_dovecot(setting_name, old_value, new_value):
environment = os.environ.copy()
environment.update({"DEBIAN_FRONTEND": "noninteractive"})
- if new_value == "True":
+ if new_value is True:
command = [
"apt-get",
"-y",
diff --git a/src/ssh.py b/src/ssh.py
index d5951cba5..2ae5ffe46 100644
--- a/src/ssh.py
+++ b/src/ssh.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
diff --git a/src/tests/conftest.py b/src/tests/conftest.py
index cd5cb307e..393c33564 100644
--- a/src/tests/conftest.py
+++ b/src/tests/conftest.py
@@ -51,7 +51,6 @@ old_translate = moulinette.core.Translator.translate
def new_translate(self, key, *args, **kwargs):
-
if key not in self._translations[self.default_locale].keys():
raise KeyError("Unable to retrieve key %s for default locale !" % key)
@@ -67,7 +66,6 @@ moulinette.core.Translator.translate = new_translate
def pytest_cmdline_main(config):
-
import sys
sys.path.insert(0, "/usr/lib/moulinette/")
@@ -76,7 +74,6 @@ def pytest_cmdline_main(config):
yunohost.init(debug=config.option.yunodebug)
class DummyInterface:
-
type = "cli"
def prompt(self, *args, **kwargs):
diff --git a/src/tests/test_app_catalog.py b/src/tests/test_app_catalog.py
index e9ecb1c12..f7363dabe 100644
--- a/src/tests/test_app_catalog.py
+++ b/src/tests/test_app_catalog.py
@@ -44,7 +44,6 @@ class AnyStringWith(str):
def setup_function(function):
-
# Clear apps catalog cache
shutil.rmtree(APPS_CATALOG_CACHE, ignore_errors=True)
@@ -54,7 +53,6 @@ def setup_function(function):
def teardown_function(function):
-
# Clear apps catalog cache
# Otherwise when using apps stuff after running the test,
# we'll still have the dummy unusable list
@@ -67,7 +65,6 @@ def teardown_function(function):
def test_apps_catalog_init(mocker):
-
# Cache is empty
assert not glob.glob(APPS_CATALOG_CACHE + "/*")
# Conf doesn't exist yet
@@ -91,7 +88,6 @@ def test_apps_catalog_init(mocker):
def test_apps_catalog_emptylist():
-
# Initialize ...
_initialize_apps_catalog_system()
@@ -104,7 +100,6 @@ def test_apps_catalog_emptylist():
def test_apps_catalog_update_nominal(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
@@ -113,7 +108,6 @@ def test_apps_catalog_update_nominal(mocker):
# Update
with requests_mock.Mocker() as m:
-
_actual_apps_catalog_api_url,
# Mock the server response with a dummy apps catalog
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG)
@@ -139,12 +133,10 @@ def test_apps_catalog_update_nominal(mocker):
def test_apps_catalog_update_404(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
with requests_mock.Mocker() as m:
-
# 404 error
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, status_code=404)
@@ -155,12 +147,10 @@ def test_apps_catalog_update_404(mocker):
def test_apps_catalog_update_timeout(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
with requests_mock.Mocker() as m:
-
# Timeout
m.register_uri(
"GET", APPS_CATALOG_DEFAULT_URL_FULL, exc=requests.exceptions.ConnectTimeout
@@ -173,12 +163,10 @@ def test_apps_catalog_update_timeout(mocker):
def test_apps_catalog_update_sslerror(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
with requests_mock.Mocker() as m:
-
# SSL error
m.register_uri(
"GET", APPS_CATALOG_DEFAULT_URL_FULL, exc=requests.exceptions.SSLError
@@ -191,12 +179,10 @@ def test_apps_catalog_update_sslerror(mocker):
def test_apps_catalog_update_corrupted(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
with requests_mock.Mocker() as m:
-
# Corrupted json
m.register_uri(
"GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG[:-2]
@@ -209,7 +195,6 @@ def test_apps_catalog_update_corrupted(mocker):
def test_apps_catalog_load_with_empty_cache(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
@@ -218,7 +203,6 @@ def test_apps_catalog_load_with_empty_cache(mocker):
# Update
with requests_mock.Mocker() as m:
-
# Mock the server response with a dummy apps catalog
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG)
@@ -237,7 +221,6 @@ def test_apps_catalog_load_with_empty_cache(mocker):
def test_apps_catalog_load_with_conflicts_between_lists(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
@@ -253,7 +236,6 @@ def test_apps_catalog_load_with_conflicts_between_lists(mocker):
# Update
with requests_mock.Mocker() as m:
-
# Mock the server response with a dummy apps catalog
# + the same apps catalog for the second list
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG)
@@ -277,13 +259,11 @@ def test_apps_catalog_load_with_conflicts_between_lists(mocker):
def test_apps_catalog_load_with_oudated_api_version(mocker):
-
# Initialize ...
_initialize_apps_catalog_system()
# Update
with requests_mock.Mocker() as m:
-
mocker.spy(m18n, "n")
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG)
_update_apps_catalog()
@@ -300,7 +280,6 @@ def test_apps_catalog_load_with_oudated_api_version(mocker):
# Update
with requests_mock.Mocker() as m:
-
# Mock the server response with a dummy apps catalog
m.register_uri("GET", APPS_CATALOG_DEFAULT_URL_FULL, text=DUMMY_APP_CATALOG)
diff --git a/src/tests/test_app_config.py b/src/tests/test_app_config.py
index b524a7a51..4a74cbc0d 100644
--- a/src/tests/test_app_config.py
+++ b/src/tests/test_app_config.py
@@ -25,17 +25,14 @@ from yunohost.utils.error import YunohostError, YunohostValidationError
def setup_function(function):
-
clean()
def teardown_function(function):
-
clean()
def clean():
-
# Make sure we have a ssowat
os.system("mkdir -p /etc/ssowat/")
app_ssowatconf()
@@ -43,7 +40,6 @@ def clean():
test_apps = ["config_app", "legacy_app"]
for test_app in test_apps:
-
if _is_installed(test_app):
app_remove(test_app)
@@ -66,7 +62,6 @@ def clean():
@pytest.fixture()
def legacy_app(request):
-
main_domain = _get_maindomain()
app_install(
@@ -85,7 +80,6 @@ def legacy_app(request):
@pytest.fixture()
def config_app(request):
-
app_install(
os.path.join(get_test_apps_dir(), "config_app_ynh"),
args="",
@@ -101,7 +95,6 @@ def config_app(request):
def test_app_config_get(config_app):
-
user_create("alice", _get_maindomain(), "test123Ynh", fullname="Alice White")
assert isinstance(app_config_get(config_app), dict)
@@ -115,13 +108,11 @@ def test_app_config_get(config_app):
def test_app_config_nopanel(legacy_app):
-
with pytest.raises(YunohostValidationError):
app_config_get(legacy_app)
def test_app_config_get_nonexistentstuff(config_app):
-
with pytest.raises(YunohostValidationError):
app_config_get("nonexistent")
@@ -140,7 +131,6 @@ def test_app_config_get_nonexistentstuff(config_app):
def test_app_config_regular_setting(config_app):
-
assert app_config_get(config_app, "main.components.boolean") == 0
app_config_set(config_app, "main.components.boolean", "no")
@@ -160,7 +150,6 @@ def test_app_config_regular_setting(config_app):
def test_app_config_bind_on_file(config_app):
-
# c.f. conf/test.php in the config app
assert '$arg5= "Arg5 value";' in read_file("/var/www/config_app/test.php")
assert app_config_get(config_app, "bind.variable.arg5") == "Arg5 value"
@@ -184,7 +173,6 @@ def test_app_config_bind_on_file(config_app):
def test_app_config_custom_validator(config_app):
-
# c.f. the config script
# arg8 is a password that must be at least 8 chars
assert not os.path.exists("/var/www/config_app/password")
@@ -198,7 +186,6 @@ def test_app_config_custom_validator(config_app):
def test_app_config_custom_set(config_app):
-
assert not os.path.exists("/var/www/config_app/password")
assert app_setting(config_app, "arg8") is None
diff --git a/src/tests/test_app_resources.py b/src/tests/test_app_resources.py
index 879f6e29a..d2df647a3 100644
--- a/src/tests/test_app_resources.py
+++ b/src/tests/test_app_resources.py
@@ -17,7 +17,6 @@ dummyfile = "/tmp/dummyappresource-testapp"
class DummyAppResource(AppResource):
-
type = "dummy"
default_properties = {
@@ -26,14 +25,12 @@ class DummyAppResource(AppResource):
}
def provision_or_update(self, context):
-
open(self.file, "w").write(self.content)
if self.content == "forbiddenvalue":
raise Exception("Emeged you used the forbidden value!1!£&")
def deprovision(self, context):
-
os.system(f"rm -f {self.file}")
@@ -41,7 +38,6 @@ AppResourceClassesByType["dummy"] = DummyAppResource
def setup_function(function):
-
clean()
os.system("mkdir /etc/yunohost/apps/testapp")
@@ -51,12 +47,10 @@ def setup_function(function):
def teardown_function(function):
-
clean()
def clean():
-
os.system(f"rm -f {dummyfile}")
os.system("rm -rf /etc/yunohost/apps/testapp")
os.system("rm -rf /var/www/testapp")
@@ -70,7 +64,6 @@ def clean():
def test_provision_dummy():
-
current = {"resources": {}}
wanted = {"resources": {"dummy": {}}}
@@ -82,7 +75,6 @@ def test_provision_dummy():
def test_deprovision_dummy():
-
current = {"resources": {"dummy": {}}}
wanted = {"resources": {}}
@@ -96,7 +88,6 @@ def test_deprovision_dummy():
def test_provision_dummy_nondefaultvalue():
-
current = {"resources": {}}
wanted = {"resources": {"dummy": {"content": "bar"}}}
@@ -108,7 +99,6 @@ def test_provision_dummy_nondefaultvalue():
def test_update_dummy():
-
current = {"resources": {"dummy": {}}}
wanted = {"resources": {"dummy": {"content": "bar"}}}
@@ -122,7 +112,6 @@ def test_update_dummy():
def test_update_dummy_failwithrollback():
-
current = {"resources": {"dummy": {}}}
wanted = {"resources": {"dummy": {"content": "forbiddenvalue"}}}
@@ -137,7 +126,6 @@ def test_update_dummy_failwithrollback():
def test_resource_system_user():
-
r = AppResourceClassesByType["system_user"]
conf = {}
@@ -161,7 +149,6 @@ def test_resource_system_user():
def test_resource_install_dir():
-
r = AppResourceClassesByType["install_dir"]
conf = {"owner": "nobody:rx", "group": "nogroup:rx"}
@@ -196,7 +183,6 @@ def test_resource_install_dir():
def test_resource_data_dir():
-
r = AppResourceClassesByType["data_dir"]
conf = {"owner": "nobody:rx", "group": "nogroup:rx"}
@@ -228,7 +214,6 @@ def test_resource_data_dir():
def test_resource_ports():
-
r = AppResourceClassesByType["ports"]
conf = {}
@@ -244,7 +229,6 @@ def test_resource_ports():
def test_resource_ports_several():
-
r = AppResourceClassesByType["ports"]
conf = {"main": {"default": 12345}, "foobar": {"default": 23456}}
@@ -263,7 +247,6 @@ def test_resource_ports_several():
def test_resource_ports_firewall():
-
r = AppResourceClassesByType["ports"]
conf = {"main": {"default": 12345}}
@@ -283,7 +266,6 @@ def test_resource_ports_firewall():
def test_resource_database():
-
r = AppResourceClassesByType["database"]
conf = {"type": "mysql"}
@@ -308,7 +290,6 @@ def test_resource_database():
def test_resource_apt():
-
r = AppResourceClassesByType["apt"]
conf = {
"packages": "nyancat, sl",
@@ -356,7 +337,6 @@ def test_resource_apt():
def test_resource_permissions():
-
maindomain = _get_maindomain()
os.system(f"echo 'domain: {maindomain}' >> /etc/yunohost/apps/testapp/settings.yml")
os.system("echo 'path: /testapp' >> /etc/yunohost/apps/testapp/settings.yml")
diff --git a/src/tests/test_apps.py b/src/tests/test_apps.py
index 6efdaa0b0..747eb5dcd 100644
--- a/src/tests/test_apps.py
+++ b/src/tests/test_apps.py
@@ -19,7 +19,7 @@ from yunohost.app import (
app_info,
)
from yunohost.domain import _get_maindomain, domain_add, domain_remove, domain_list
-from yunohost.utils.error import YunohostError
+from yunohost.utils.error import YunohostError, YunohostValidationError
from yunohost.tests.test_permission import (
check_LDAP_db_integrity,
check_permission_for_apps,
@@ -28,17 +28,14 @@ from yunohost.permission import user_permission_list, permission_delete
def setup_function(function):
-
clean()
def teardown_function(function):
-
clean()
def clean():
-
# Make sure we have a ssowat
os.system("mkdir -p /etc/ssowat/")
app_ssowatconf()
@@ -53,7 +50,6 @@ def clean():
]
for test_app in test_apps:
-
if _is_installed(test_app):
app_remove(test_app)
@@ -95,7 +91,6 @@ def check_permission_for_apps_call():
@pytest.fixture(scope="module")
def secondary_domain(request):
-
if "example.test" not in domain_list()["domains"]:
domain_add("example.test")
@@ -113,7 +108,6 @@ def secondary_domain(request):
def app_expected_files(domain, app):
-
yield "/etc/nginx/conf.d/{}.d/{}.conf".format(domain, app)
if app.startswith("legacy_app"):
yield "/var/www/%s/index.html" % app
@@ -127,21 +121,18 @@ def app_expected_files(domain, app):
def app_is_installed(domain, app):
-
return _is_installed(app) and all(
os.path.exists(f) for f in app_expected_files(domain, app)
)
def app_is_not_installed(domain, app):
-
return not _is_installed(app) and not all(
os.path.exists(f) for f in app_expected_files(domain, app)
)
def app_is_exposed_on_http(domain, path, message_in_page):
-
try:
r = requests.get(
"https://127.0.0.1" + path + "/",
@@ -155,7 +146,6 @@ def app_is_exposed_on_http(domain, path, message_in_page):
def install_legacy_app(domain, path, public=True):
-
app_install(
os.path.join(get_test_apps_dir(), "legacy_app_ynh"),
args="domain={}&path={}&is_public={}".format(domain, path, 1 if public else 0),
@@ -164,7 +154,6 @@ def install_legacy_app(domain, path, public=True):
def install_manifestv2_app(domain, path, public=True):
-
app_install(
os.path.join(get_test_apps_dir(), "manifestv2_app_ynh"),
args="domain={}&path={}&init_main_permission={}".format(
@@ -175,7 +164,6 @@ def install_manifestv2_app(domain, path, public=True):
def install_full_domain_app(domain):
-
app_install(
os.path.join(get_test_apps_dir(), "full_domain_app_ynh"),
args="domain=%s" % domain,
@@ -184,7 +172,6 @@ def install_full_domain_app(domain):
def install_break_yo_system(domain, breakwhat):
-
app_install(
os.path.join(get_test_apps_dir(), "break_yo_system_ynh"),
args="domain={}&breakwhat={}".format(domain, breakwhat),
@@ -193,7 +180,6 @@ def install_break_yo_system(domain, breakwhat):
def test_legacy_app_install_main_domain():
-
main_domain = _get_maindomain()
install_legacy_app(main_domain, "/legacy")
@@ -213,7 +199,6 @@ def test_legacy_app_install_main_domain():
def test_legacy_app_manifest_preinstall():
-
m = app_manifest(os.path.join(get_test_apps_dir(), "legacy_app_ynh"))
# v1 manifesto are expected to have been autoconverted to v2
@@ -231,7 +216,6 @@ def test_legacy_app_manifest_preinstall():
def test_manifestv2_app_manifest_preinstall():
-
m = app_manifest(os.path.join(get_test_apps_dir(), "manifestv2_app_ynh"))
assert "id" in m
@@ -258,7 +242,6 @@ def test_manifestv2_app_manifest_preinstall():
def test_manifestv2_app_install_main_domain():
-
main_domain = _get_maindomain()
install_manifestv2_app(main_domain, "/manifestv2")
@@ -278,7 +261,6 @@ def test_manifestv2_app_install_main_domain():
def test_manifestv2_app_info_postinstall():
-
main_domain = _get_maindomain()
install_manifestv2_app(main_domain, "/manifestv2")
m = app_info("manifestv2_app", full=True)["manifest"]
@@ -308,13 +290,11 @@ def test_manifestv2_app_info_postinstall():
def test_manifestv2_app_info_preupgrade(monkeypatch):
-
manifest = app_manifest(os.path.join(get_test_apps_dir(), "manifestv2_app_ynh"))
from yunohost.app_catalog import _load_apps_catalog as original_load_apps_catalog
def custom_load_apps_catalog(*args, **kwargs):
-
res = original_load_apps_catalog(*args, **kwargs)
res["apps"]["manifestv2_app"] = {
"id": "manifestv2_app",
@@ -372,7 +352,6 @@ def test_app_from_catalog():
def test_legacy_app_install_secondary_domain(secondary_domain):
-
install_legacy_app(secondary_domain, "/legacy")
assert app_is_installed(secondary_domain, "legacy_app")
@@ -384,7 +363,6 @@ def test_legacy_app_install_secondary_domain(secondary_domain):
def test_legacy_app_install_secondary_domain_on_root(secondary_domain):
-
install_legacy_app(secondary_domain, "/")
app_map_ = app_map(raw=True)
@@ -402,7 +380,6 @@ def test_legacy_app_install_secondary_domain_on_root(secondary_domain):
def test_legacy_app_install_private(secondary_domain):
-
install_legacy_app(secondary_domain, "/legacy", public=False)
assert app_is_installed(secondary_domain, "legacy_app")
@@ -416,7 +393,6 @@ def test_legacy_app_install_private(secondary_domain):
def test_legacy_app_install_unknown_domain(mocker):
-
with pytest.raises(YunohostError):
with message(mocker, "app_argument_invalid"):
install_legacy_app("whatever.nope", "/legacy")
@@ -425,7 +401,6 @@ def test_legacy_app_install_unknown_domain(mocker):
def test_legacy_app_install_multiple_instances(secondary_domain):
-
install_legacy_app(secondary_domain, "/foo")
install_legacy_app(secondary_domain, "/bar")
@@ -447,7 +422,6 @@ def test_legacy_app_install_multiple_instances(secondary_domain):
def test_legacy_app_install_path_unavailable(mocker, secondary_domain):
-
# These will be removed in teardown
install_legacy_app(secondary_domain, "/legacy")
@@ -460,7 +434,6 @@ def test_legacy_app_install_path_unavailable(mocker, secondary_domain):
def test_legacy_app_install_with_nginx_down(mocker, secondary_domain):
-
os.system("systemctl stop nginx")
with raiseYunohostError(
@@ -470,7 +443,6 @@ def test_legacy_app_install_with_nginx_down(mocker, secondary_domain):
def test_legacy_app_failed_install(mocker, secondary_domain):
-
# This will conflict with the folder that the app
# attempts to create, making the install fail
mkdir("/var/www/legacy_app/", 0o750)
@@ -483,7 +455,6 @@ def test_legacy_app_failed_install(mocker, secondary_domain):
def test_legacy_app_failed_remove(mocker, secondary_domain):
-
install_legacy_app(secondary_domain, "/legacy")
# The remove script runs with set -eu and attempt to remove this
@@ -503,14 +474,12 @@ def test_legacy_app_failed_remove(mocker, secondary_domain):
def test_full_domain_app(secondary_domain):
-
install_full_domain_app(secondary_domain)
assert app_is_exposed_on_http(secondary_domain, "/", "This is a dummy app")
def test_full_domain_app_with_conflicts(mocker, secondary_domain):
-
install_legacy_app(secondary_domain, "/legacy")
with raiseYunohostError(mocker, "app_full_domain_unavailable"):
@@ -518,7 +487,6 @@ def test_full_domain_app_with_conflicts(mocker, secondary_domain):
def test_systemfuckedup_during_app_install(mocker, secondary_domain):
-
with pytest.raises(YunohostError):
with message(mocker, "app_install_failed"):
with message(mocker, "app_action_broke_system"):
@@ -528,7 +496,6 @@ def test_systemfuckedup_during_app_install(mocker, secondary_domain):
def test_systemfuckedup_during_app_remove(mocker, secondary_domain):
-
install_break_yo_system(secondary_domain, breakwhat="remove")
with pytest.raises(YunohostError):
@@ -540,7 +507,6 @@ def test_systemfuckedup_during_app_remove(mocker, secondary_domain):
def test_systemfuckedup_during_app_install_and_remove(mocker, secondary_domain):
-
with pytest.raises(YunohostError):
with message(mocker, "app_install_failed"):
with message(mocker, "app_action_broke_system"):
@@ -550,7 +516,6 @@ def test_systemfuckedup_during_app_install_and_remove(mocker, secondary_domain):
def test_systemfuckedup_during_app_upgrade(mocker, secondary_domain):
-
install_break_yo_system(secondary_domain, breakwhat="upgrade")
with pytest.raises(YunohostError):
@@ -562,7 +527,6 @@ def test_systemfuckedup_during_app_upgrade(mocker, secondary_domain):
def test_failed_multiple_app_upgrade(mocker, secondary_domain):
-
install_legacy_app(secondary_domain, "/legacy")
install_break_yo_system(secondary_domain, breakwhat="upgrade")
@@ -577,3 +541,176 @@ def test_failed_multiple_app_upgrade(mocker, secondary_domain):
"legacy": os.path.join(get_test_apps_dir(), "legacy_app_ynh"),
},
)
+
+
+class TestMockedAppUpgrade:
+ """
+ This class is here to test the logical workflow of app_upgrade and thus
+ mock nearly all side effects
+ """
+
+ def setup_method(self, method):
+ self.apps_list = []
+ self.upgradable_apps_list = []
+
+ def _mock_app_upgrade(self, mocker):
+ # app list
+ self._installed_apps = mocker.patch(
+ "yunohost.app._installed_apps", side_effect=lambda: self.apps_list
+ )
+
+ # just check if an app is really installed
+ mocker.patch(
+ "yunohost.app._is_installed", side_effect=lambda app: app in self.apps_list
+ )
+
+ # app_dict =
+ mocker.patch(
+ "yunohost.app.app_info",
+ side_effect=lambda app, full: {
+ "upgradable": "yes" if app in self.upgradable_apps_list else "no",
+ "manifest": {"id": app},
+ "version": "?",
+ },
+ )
+
+ def custom_extract_app(app):
+ return (
+ {
+ "version": "?",
+ "packaging_format": 1,
+ "id": app,
+ "notifications": {"PRE_UPGRADE": None, "POST_UPGRADE": None},
+ },
+ "MOCKED_BY_TEST",
+ )
+
+ # return (manifest, extracted_app_folder)
+ mocker.patch("yunohost.app._extract_app", side_effect=custom_extract_app)
+
+ # for [(name, passed, values, err), ...] in
+ mocker.patch(
+ "yunohost.app._check_manifest_requirements",
+ return_value=[(None, True, None, None)],
+ )
+
+ # raise on failure
+ mocker.patch("yunohost.app._assert_system_is_sane_for_app", return_value=True)
+
+ from os.path import exists # import the unmocked function
+
+ def custom_os_path_exists(path):
+ if path.endswith("manifest.toml"):
+ return True
+ return exists(path)
+
+ mocker.patch("os.path.exists", side_effect=custom_os_path_exists)
+
+ # manifest =
+ mocker.patch(
+ "yunohost.app.read_toml", return_value={"arguments": {"install": []}}
+ )
+
+ # install_failed, failure_message_with_debug_instructions =
+ self.hook_exec_with_script_debug_if_failure = mocker.patch(
+ "yunohost.hook.hook_exec_with_script_debug_if_failure",
+ return_value=(False, ""),
+ )
+ # settings =
+ mocker.patch("yunohost.app._get_app_settings", return_value={})
+ # return nothing
+ mocker.patch("yunohost.app._set_app_settings")
+
+ from os import listdir # import the unmocked function
+
+ def custom_os_listdir(path):
+ if path.endswith("MOCKED_BY_TEST"):
+ return []
+ return listdir(path)
+
+ mocker.patch("os.listdir", side_effect=custom_os_listdir)
+ mocker.patch("yunohost.app.rm")
+ mocker.patch("yunohost.app.cp")
+ mocker.patch("shutil.rmtree")
+ mocker.patch("yunohost.app.chmod")
+ mocker.patch("yunohost.app.chown")
+ mocker.patch("yunohost.app.app_ssowatconf")
+
+ def test_app_upgrade_no_apps(self, mocker):
+ self._mock_app_upgrade(mocker)
+
+ with pytest.raises(YunohostValidationError):
+ app_upgrade()
+
+ def test_app_upgrade_app_not_install(self, mocker):
+ self._mock_app_upgrade(mocker)
+
+ with pytest.raises(YunohostValidationError):
+ app_upgrade("some_app")
+
+ def test_app_upgrade_one_app(self, mocker):
+ self._mock_app_upgrade(mocker)
+ self.apps_list = ["some_app"]
+
+ # yunohost is happy, not apps to upgrade
+ app_upgrade()
+
+ self.hook_exec_with_script_debug_if_failure.assert_not_called()
+
+ self.upgradable_apps_list.append("some_app")
+ app_upgrade()
+
+ self.hook_exec_with_script_debug_if_failure.assert_called_once()
+ assert (
+ self.hook_exec_with_script_debug_if_failure.call_args.kwargs["env"][
+ "YNH_APP_ID"
+ ]
+ == "some_app"
+ )
+
+ def test_app_upgrade_continue_on_failure(self, mocker):
+ self._mock_app_upgrade(mocker)
+ self.apps_list = ["a", "b", "c"]
+ self.upgradable_apps_list = self.apps_list
+
+ def fails_on_b(self, *args, env, **kwargs):
+ if env["YNH_APP_ID"] == "b":
+ return True, "failed"
+ return False, "ok"
+
+ self.hook_exec_with_script_debug_if_failure.side_effect = fails_on_b
+
+ with pytest.raises(YunohostError):
+ app_upgrade()
+
+ app_upgrade(continue_on_failure=True)
+
+ def test_app_upgrade_continue_on_failure_broken_system(self, mocker):
+ """--continue-on-failure should stop on a broken system"""
+
+ self._mock_app_upgrade(mocker)
+ self.apps_list = ["a", "broke_the_system", "c"]
+ self.upgradable_apps_list = self.apps_list
+
+ def fails_on_b(self, *args, env, **kwargs):
+ if env["YNH_APP_ID"] == "broke_the_system":
+ return True, "failed"
+ return False, "ok"
+
+ self.hook_exec_with_script_debug_if_failure.side_effect = fails_on_b
+
+ def _assert_system_is_sane_for_app(manifest, state):
+ if state == "post" and manifest["id"] == "broke_the_system":
+ raise Exception()
+ return True
+
+ mocker.patch(
+ "yunohost.app._assert_system_is_sane_for_app",
+ side_effect=_assert_system_is_sane_for_app,
+ )
+
+ with pytest.raises(YunohostError):
+ app_upgrade()
+
+ with pytest.raises(YunohostError):
+ app_upgrade(continue_on_failure=True)
diff --git a/src/tests/test_appurl.py b/src/tests/test_appurl.py
index c036ae28a..351bb4e83 100644
--- a/src/tests/test_appurl.py
+++ b/src/tests/test_appurl.py
@@ -18,7 +18,6 @@ maindomain = _get_maindomain()
def setup_function(function):
-
try:
app_remove("register_url_app")
except Exception:
@@ -26,7 +25,6 @@ def setup_function(function):
def teardown_function(function):
-
try:
app_remove("register_url_app")
except Exception:
@@ -34,7 +32,6 @@ def teardown_function(function):
def test_parse_app_instance_name():
-
assert _parse_app_instance_name("yolo") == ("yolo", 1)
assert _parse_app_instance_name("yolo1") == ("yolo1", 1)
assert _parse_app_instance_name("yolo__0") == ("yolo__0", 1)
@@ -86,7 +83,6 @@ def test_repo_url_definition():
def test_urlavailable():
-
# Except the maindomain/macnuggets to be available
assert domain_url_available(maindomain, "/macnuggets")
@@ -96,7 +92,6 @@ def test_urlavailable():
def test_registerurl():
-
app_install(
os.path.join(get_test_apps_dir(), "register_url_app_ynh"),
args="domain={}&path={}".format(maindomain, "/urlregisterapp"),
@@ -115,7 +110,6 @@ def test_registerurl():
def test_registerurl_baddomain():
-
with pytest.raises(YunohostError):
app_install(
os.path.join(get_test_apps_dir(), "register_url_app_ynh"),
diff --git a/src/tests/test_backuprestore.py b/src/tests/test_backuprestore.py
index dc37d3497..413d44470 100644
--- a/src/tests/test_backuprestore.py
+++ b/src/tests/test_backuprestore.py
@@ -6,6 +6,8 @@ from mock import patch
from .conftest import message, raiseYunohostError, get_test_apps_dir
+from moulinette.utils.text import random_ascii
+
from yunohost.app import app_install, app_remove, app_ssowatconf
from yunohost.app import _is_installed
from yunohost.backup import (
@@ -30,7 +32,6 @@ maindomain = ""
def setup_function(function):
-
global maindomain
maindomain = _get_maindomain()
@@ -89,7 +90,6 @@ def setup_function(function):
def teardown_function(function):
-
assert tmp_backup_directory_is_empty()
reset_ssowat_conf()
@@ -133,7 +133,6 @@ def check_permission_for_apps_call():
def app_is_installed(app):
-
if app == "permissions_app":
return _is_installed(app)
@@ -147,7 +146,6 @@ def app_is_installed(app):
def backup_test_dependencies_are_met():
-
# Dummy test apps (or backup archives)
assert os.path.exists(
os.path.join(get_test_apps_dir(), "backup_wordpress_from_4p2")
@@ -161,7 +159,6 @@ def backup_test_dependencies_are_met():
def tmp_backup_directory_is_empty():
-
if not os.path.exists("/home/yunohost.backup/tmp/"):
return True
else:
@@ -169,7 +166,6 @@ def tmp_backup_directory_is_empty():
def clean_tmp_backup_directory():
-
if tmp_backup_directory_is_empty():
return
@@ -191,27 +187,23 @@ def clean_tmp_backup_directory():
def reset_ssowat_conf():
-
# Make sure we have a ssowat
os.system("mkdir -p /etc/ssowat/")
app_ssowatconf()
def delete_all_backups():
-
for archive in backup_list()["archives"]:
backup_delete(archive)
def uninstall_test_apps_if_needed():
-
for app in ["legacy_app", "backup_recommended_app", "wordpress", "permissions_app"]:
if _is_installed(app):
app_remove(app)
def install_app(app, path, additionnal_args=""):
-
app_install(
os.path.join(get_test_apps_dir(), app),
args="domain={}&path={}{}".format(maindomain, path, additionnal_args),
@@ -220,7 +212,6 @@ def install_app(app, path, additionnal_args=""):
def add_archive_wordpress_from_4p2():
-
os.system("mkdir -p /home/yunohost.backup/archives")
os.system(
@@ -231,7 +222,6 @@ def add_archive_wordpress_from_4p2():
def add_archive_system_from_4p2():
-
os.system("mkdir -p /home/yunohost.backup/archives")
os.system(
@@ -247,10 +237,10 @@ def add_archive_system_from_4p2():
def test_backup_only_ldap(mocker):
-
# Create the backup
- with message(mocker, "backup_created"):
- backup_create(system=["conf_ldap"], apps=None)
+ name = random_ascii(8)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=["conf_ldap"], apps=None)
archives = backup_list()["archives"]
assert len(archives) == 1
@@ -262,7 +252,6 @@ def test_backup_only_ldap(mocker):
def test_backup_system_part_that_does_not_exists(mocker):
-
# Create the backup
with message(mocker, "backup_hook_unknown", hook="doesnt_exist"):
with raiseYunohostError(mocker, "backup_nothings_done"):
@@ -275,10 +264,10 @@ def test_backup_system_part_that_does_not_exists(mocker):
def test_backup_and_restore_all_sys(mocker):
-
+ name = random_ascii(8)
# Create the backup
- with message(mocker, "backup_created"):
- backup_create(system=[], apps=None)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=[], apps=None)
archives = backup_list()["archives"]
assert len(archives) == 1
@@ -309,10 +298,10 @@ def test_backup_and_restore_all_sys(mocker):
@pytest.mark.with_system_archive_from_4p2
def test_restore_system_from_Ynh4p2(monkeypatch, mocker):
-
+ name = random_ascii(8)
# Backup current system
- with message(mocker, "backup_created"):
- backup_create(system=[], apps=None)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=[], apps=None)
archives = backup_list()["archives"]
assert len(archives) == 2
@@ -337,7 +326,6 @@ def test_restore_system_from_Ynh4p2(monkeypatch, mocker):
@pytest.mark.with_backup_recommended_app_installed
def test_backup_script_failure_handling(monkeypatch, mocker):
def custom_hook_exec(name, *args, **kwargs):
-
if os.path.basename(name).startswith("backup_"):
raise Exception
else:
@@ -373,7 +361,6 @@ def test_backup_not_enough_free_space(monkeypatch, mocker):
def test_backup_app_not_installed(mocker):
-
assert not _is_installed("wordpress")
with message(mocker, "unbackup_app", app="wordpress"):
@@ -383,7 +370,6 @@ def test_backup_app_not_installed(mocker):
@pytest.mark.with_backup_recommended_app_installed
def test_backup_app_with_no_backup_script(mocker):
-
backup_script = "/etc/yunohost/apps/backup_recommended_app/scripts/backup"
os.system("rm %s" % backup_script)
assert not os.path.exists(backup_script)
@@ -397,7 +383,6 @@ def test_backup_app_with_no_backup_script(mocker):
@pytest.mark.with_backup_recommended_app_installed
def test_backup_app_with_no_restore_script(mocker):
-
restore_script = "/etc/yunohost/apps/backup_recommended_app/scripts/restore"
os.system("rm %s" % restore_script)
assert not os.path.exists(restore_script)
@@ -413,17 +398,17 @@ def test_backup_app_with_no_restore_script(mocker):
@pytest.mark.clean_opt_dir
def test_backup_with_different_output_directory(mocker):
-
+ name = random_ascii(8)
# Create the backup
- with message(mocker, "backup_created"):
+ with message(mocker, "backup_created", name=name):
backup_create(
system=["conf_ynh_settings"],
apps=None,
output_directory="/opt/test_backup_output_directory",
- name="backup",
+ name=name,
)
- assert os.path.exists("/opt/test_backup_output_directory/backup.tar")
+ assert os.path.exists(f"/opt/test_backup_output_directory/{name}.tar")
archives = backup_list()["archives"]
assert len(archives) == 1
@@ -436,15 +421,15 @@ def test_backup_with_different_output_directory(mocker):
@pytest.mark.clean_opt_dir
def test_backup_using_copy_method(mocker):
-
# Create the backup
- with message(mocker, "backup_created"):
+ name = random_ascii(8)
+ with message(mocker, "backup_created", name=name):
backup_create(
system=["conf_ynh_settings"],
apps=None,
output_directory="/opt/test_backup_output_directory",
methods=["copy"],
- name="backup",
+ name=name,
)
assert os.path.exists("/opt/test_backup_output_directory/info.json")
@@ -458,7 +443,6 @@ def test_backup_using_copy_method(mocker):
@pytest.mark.with_wordpress_archive_from_4p2
@pytest.mark.with_custom_domain("yolo.test")
def test_restore_app_wordpress_from_Ynh4p2(mocker):
-
with message(mocker, "restore_complete"):
backup_restore(
system=None, name=backup_list()["archives"][0], apps=["wordpress"]
@@ -507,7 +491,6 @@ def test_restore_app_not_enough_free_space(monkeypatch, mocker):
@pytest.mark.with_wordpress_archive_from_4p2
def test_restore_app_not_in_backup(mocker):
-
assert not _is_installed("wordpress")
assert not _is_installed("yoloswag")
@@ -524,7 +507,6 @@ def test_restore_app_not_in_backup(mocker):
@pytest.mark.with_wordpress_archive_from_4p2
@pytest.mark.with_custom_domain("yolo.test")
def test_restore_app_already_installed(mocker):
-
assert not _is_installed("wordpress")
with message(mocker, "restore_complete"):
@@ -544,25 +526,21 @@ def test_restore_app_already_installed(mocker):
@pytest.mark.with_legacy_app_installed
def test_backup_and_restore_legacy_app(mocker):
-
_test_backup_and_restore_app(mocker, "legacy_app")
@pytest.mark.with_backup_recommended_app_installed
def test_backup_and_restore_recommended_app(mocker):
-
_test_backup_and_restore_app(mocker, "backup_recommended_app")
@pytest.mark.with_backup_recommended_app_installed_with_ynh_restore
def test_backup_and_restore_with_ynh_restore(mocker):
-
_test_backup_and_restore_app(mocker, "backup_recommended_app")
@pytest.mark.with_permission_app_installed
def test_backup_and_restore_permission_app(mocker):
-
res = user_permission_list(full=True)["permissions"]
assert "permissions_app.main" in res
assert "permissions_app.admin" in res
@@ -593,10 +571,10 @@ def test_backup_and_restore_permission_app(mocker):
def _test_backup_and_restore_app(mocker, app):
-
# Create a backup of this app
- with message(mocker, "backup_created"):
- backup_create(system=None, apps=[app])
+ name = random_ascii(8)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=None, apps=[app])
archives = backup_list()["archives"]
assert len(archives) == 1
@@ -628,7 +606,6 @@ def _test_backup_and_restore_app(mocker, app):
def test_restore_archive_with_no_json(mocker):
-
# Create a backup with no info.json associated
os.system("touch /tmp/afile")
os.system("tar -cvf /home/yunohost.backup/archives/badbackup.tar /tmp/afile")
@@ -641,7 +618,6 @@ def test_restore_archive_with_no_json(mocker):
@pytest.mark.with_wordpress_archive_from_4p2
def test_restore_archive_with_bad_archive(mocker):
-
# Break the archive
os.system(
"head -n 1000 /home/yunohost.backup/archives/backup_wordpress_from_4p2.tar > /home/yunohost.backup/archives/backup_wordpress_from_4p2_bad.tar"
@@ -656,13 +632,13 @@ def test_restore_archive_with_bad_archive(mocker):
def test_restore_archive_with_custom_hook(mocker):
-
custom_restore_hook_folder = os.path.join(CUSTOM_HOOK_FOLDER, "restore")
os.system("touch %s/99-yolo" % custom_restore_hook_folder)
# Backup with custom hook system
- with message(mocker, "backup_created"):
- backup_create(system=[], apps=None)
+ name = random_ascii(8)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=[], apps=None)
archives = backup_list()["archives"]
assert len(archives) == 1
@@ -699,5 +675,6 @@ def test_backup_binds_are_readonly(mocker, monkeypatch):
)
# Create the backup
- with message(mocker, "backup_created"):
- backup_create(system=[])
+ name = random_ascii(8)
+ with message(mocker, "backup_created", name=name):
+ backup_create(name=name, system=[])
diff --git a/src/tests/test_dns.py b/src/tests/test_dns.py
index a23ac7982..e896d9c9f 100644
--- a/src/tests/test_dns.py
+++ b/src/tests/test_dns.py
@@ -12,12 +12,10 @@ from yunohost.dns import (
def setup_function(function):
-
clean()
def teardown_function(function):
-
clean()
@@ -76,7 +74,6 @@ def example_domain():
def test_domain_dns_suggest(example_domain):
-
assert _build_dns_conf(example_domain)
diff --git a/src/tests/test_domains.py b/src/tests/test_domains.py
index 43c04bee6..b7625ff7c 100644
--- a/src/tests/test_domains.py
+++ b/src/tests/test_domains.py
@@ -24,7 +24,6 @@ TEST_DYNDNS_PASSWORD = "astrongandcomplicatedpassphrasethatisverysecure"
def setup_function(function):
-
# Save domain list in variable to avoid multiple calls to domain_list()
domains = domain_list()["domains"]
@@ -57,7 +56,6 @@ def setup_function(function):
def teardown_function(function):
-
clean()
@@ -123,7 +121,6 @@ def test_domain_config_get_default():
def test_domain_config_get_export():
-
assert domain_config_get(TEST_DOMAINS[0], export=True)["xmpp"] == 1
assert domain_config_get(TEST_DOMAINS[1], export=True)["xmpp"] == 0
diff --git a/src/tests/test_ldapauth.py b/src/tests/test_ldapauth.py
index e8a48aa6d..9e3ae36cc 100644
--- a/src/tests/test_ldapauth.py
+++ b/src/tests/test_ldapauth.py
@@ -10,7 +10,6 @@ from moulinette.core import MoulinetteError
def setup_function(function):
-
for u in user_list()["users"]:
user_delete(u, purge=True)
@@ -24,7 +23,6 @@ def setup_function(function):
def teardown_function():
-
os.system("systemctl is-active slapd >/dev/null || systemctl start slapd; sleep 5")
for u in user_list()["users"]:
@@ -36,7 +34,6 @@ def test_authenticate():
def test_authenticate_with_no_user():
-
with pytest.raises(MoulinetteError):
LDAPAuth().authenticate_credentials(credentials="Yunohost")
@@ -45,7 +42,6 @@ def test_authenticate_with_no_user():
def test_authenticate_with_user_who_is_not_admin():
-
with pytest.raises(MoulinetteError) as exception:
LDAPAuth().authenticate_credentials(credentials="bob:test123Ynh")
@@ -70,7 +66,6 @@ def test_authenticate_server_down(mocker):
def test_authenticate_change_password():
-
LDAPAuth().authenticate_credentials(credentials="alice:Yunohost")
user_update("alice", change_password="plopette")
diff --git a/src/tests/test_permission.py b/src/tests/test_permission.py
index acb3419c9..10bd018d2 100644
--- a/src/tests/test_permission.py
+++ b/src/tests/test_permission.py
@@ -354,7 +354,6 @@ def check_permission_for_apps():
def can_access_webpage(webpath, logged_as=None):
-
webpath = webpath.rstrip("/")
sso_url = "https://" + maindomain + "/yunohost/sso/"
@@ -1094,7 +1093,6 @@ def test_permission_protection_management_by_helper():
@pytest.mark.other_domains(number=1)
def test_permission_app_propagation_on_ssowat():
-
app_install(
os.path.join(get_test_apps_dir(), "permissions_app_ynh"),
args="domain=%s&domain_2=%s&path=%s&is_public=1&admin=%s"
@@ -1131,7 +1129,6 @@ def test_permission_app_propagation_on_ssowat():
@pytest.mark.other_domains(number=1)
def test_permission_legacy_app_propagation_on_ssowat():
-
app_install(
os.path.join(get_test_apps_dir(), "legacy_app_ynh"),
args="domain=%s&domain_2=%s&path=%s&is_public=1"
diff --git a/src/tests/test_questions.py b/src/tests/test_questions.py
index e49047469..506fde077 100644
--- a/src/tests/test_questions.py
+++ b/src/tests/test_questions.py
@@ -1,15 +1,23 @@
+import inspect
import sys
import pytest
import os
+import tempfile
+from contextlib import contextmanager
from mock import patch
from io import StringIO
+from typing import Any, Literal, Sequence, TypedDict, Union
+
+from _pytest.mark.structures import ParameterSet
+
from moulinette import Moulinette
-
-from yunohost import domain, user
-from yunohost.utils.config import (
+from yunohost import app, domain, user
+from yunohost.utils.form import (
+ ARGUMENTS_TYPE_PARSERS,
ask_questions_and_parse_answers,
+ DisplayTextQuestion,
PasswordQuestion,
DomainQuestion,
PathQuestion,
@@ -44,40 +52,1926 @@ User answers:
"""
-def test_question_empty():
+# ╭───────────────────────────────────────────────────────╮
+# │ ┌─╮╭─┐╶┬╴╭─╴╷ ╷╶┬╴╭╮╷╭─╮ │
+# │ ├─╯├─┤ │ │ ├─┤ │ ││││╶╮ │
+# │ ╵ ╵ ╵ ╵ ╰─╴╵ ╵╶┴╴╵╰╯╰─╯ │
+# ╰───────────────────────────────────────────────────────╯
+
+
+@contextmanager
+def patch_isatty(isatty):
+ with patch.object(os, "isatty", return_value=isatty):
+ yield
+
+
+@contextmanager
+def patch_interface(interface: Literal["api", "cli"] = "api"):
+ with patch.object(Moulinette.interface, "type", interface), patch_isatty(
+ interface == "cli"
+ ):
+ yield
+
+
+@contextmanager
+def patch_prompt(return_value):
+ with patch_interface("cli"), patch.object(
+ Moulinette, "prompt", return_value=return_value
+ ) as prompt:
+ yield prompt
+
+
+@pytest.fixture
+def patch_no_tty():
+ with patch_isatty(False):
+ yield
+
+
+@pytest.fixture
+def patch_with_tty():
+ with patch_isatty(True):
+ yield
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ ╭─╴╭─╴┌─╴╭╮╷╭─┐┌─╮╶┬╴╭─╮╭─╴ │
+# │ ╰─╮│ ├─╴│││├─┤├┬╯ │ │ │╰─╮ │
+# │ ╶─╯╰─╴╰─╴╵╰╯╵ ╵╵ ╰╶┴╴╰─╯╶─╯ │
+# ╰───────────────────────────────────────────────────────╯
+
+
+MinScenario = tuple[Any, Union[Literal["FAIL"], Any]]
+PartialScenario = tuple[Any, Union[Literal["FAIL"], Any], dict[str, Any]]
+FullScenario = tuple[Any, Union[Literal["FAIL"], Any], dict[str, Any], dict[str, Any]]
+
+Scenario = Union[
+ MinScenario,
+ PartialScenario,
+ FullScenario,
+ "InnerScenario",
+]
+
+
+class InnerScenario(TypedDict, total=False):
+ scenarios: Sequence[Scenario]
+ raw_options: Sequence[dict[str, Any]]
+ data: Sequence[dict[str, Any]]
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ Scenario generators/helpers │
+# ╰───────────────────────────────────────────────────────╯
+
+
+def get_hydrated_scenarios(raw_options, scenarios, data=[{}]):
+ """
+ Normalize and hydrate a mixed list of scenarios to proper tuple/pytest.param flattened list values.
+
+ Example::
+ scenarios = [
+ {
+ "raw_options": [{}, {"optional": True}],
+ "scenarios": [
+ ("", "value", {"default": "value"}),
+ *unchanged("value", "other"),
+ ]
+ },
+ *all_fails(-1, 0, 1, raw_options={"optional": True}),
+ *xfail(scenarios=[(True, "True"), (False, "False)], reason="..."),
+ ]
+ # Is exactly the same as
+ scenarios = [
+ ("", "value", {"default": "value"}),
+ ("", "value", {"optional": True, "default": "value"}),
+ ("value", "value", {}),
+ ("value", "value", {"optional": True}),
+ ("other", "other", {}),
+ ("other", "other", {"optional": True}),
+ (-1, FAIL, {"optional": True}),
+ (0, FAIL, {"optional": True}),
+ (1, FAIL, {"optional": True}),
+ pytest.param(True, "True", {}, marks=pytest.mark.xfail(reason="...")),
+ pytest.param(False, "False", {}, marks=pytest.mark.xfail(reason="...")),
+ ]
+ """
+ hydrated_scenarios = []
+ for raw_option in raw_options:
+ for mocked_data in data:
+ for scenario in scenarios:
+ if isinstance(scenario, dict):
+ merged_raw_options = [
+ {**raw_option, **raw_opt}
+ for raw_opt in scenario.get("raw_options", [{}])
+ ]
+ hydrated_scenarios += get_hydrated_scenarios(
+ merged_raw_options,
+ scenario["scenarios"],
+ scenario.get("data", [mocked_data]),
+ )
+ elif isinstance(scenario, ParameterSet):
+ intake, output, custom_raw_option = (
+ scenario.values
+ if len(scenario.values) == 3
+ else (*scenario.values, {})
+ )
+ merged_raw_option = {**raw_option, **custom_raw_option}
+ hydrated_scenarios.append(
+ pytest.param(
+ intake,
+ output,
+ merged_raw_option,
+ mocked_data,
+ marks=scenario.marks,
+ )
+ )
+ elif isinstance(scenario, tuple):
+ intake, output, custom_raw_option = (
+ scenario if len(scenario) == 3 else (*scenario, {})
+ )
+ merged_raw_option = {**raw_option, **custom_raw_option}
+ hydrated_scenarios.append(
+ (intake, output, merged_raw_option, mocked_data)
+ )
+ else:
+ raise Exception(
+ "Test scenario should be tuple(intake, output, raw_option), pytest.param(intake, output, raw_option) or dict(raw_options, scenarios, data)"
+ )
+
+ return hydrated_scenarios
+
+
+def generate_test_name(intake, output, raw_option, data):
+ values_as_str = []
+ for value in (intake, output):
+ if isinstance(value, str) and value != FAIL:
+ values_as_str.append(f"'{value}'")
+ elif inspect.isclass(value) and issubclass(value, Exception):
+ values_as_str.append(value.__name__)
+ else:
+ values_as_str.append(value)
+ name = f"{values_as_str[0]} -> {values_as_str[1]}"
+
+ keys = [
+ "=".join(
+ [
+ key,
+ str(raw_option[key])
+ if not isinstance(raw_option[key], str)
+ else f"'{raw_option[key]}'",
+ ]
+ )
+ for key in raw_option.keys()
+ if key not in ("id", "type")
+ ]
+ if keys:
+ name += " (" + ",".join(keys) + ")"
+ return name
+
+
+def pytest_generate_tests(metafunc):
+ """
+ Pytest test factory that, for each `BaseTest` subclasses, parametrize its
+ methods if it requires it by checking the method's parameters.
+ For those and based on their `cls.scenarios`, a series of `pytest.param` are
+ automaticly injected as test values.
+ """
+ if metafunc.cls and issubclass(metafunc.cls, BaseTest):
+ argnames = []
+ argvalues = []
+ ids = []
+ fn_params = inspect.signature(metafunc.function).parameters
+
+ for params in [
+ ["intake", "expected_output", "raw_option", "data"],
+ ["intake", "expected_normalized", "raw_option", "data"],
+ ["intake", "expected_humanized", "raw_option", "data"],
+ ]:
+ if all(param in fn_params for param in params):
+ argnames += params
+ if params[1] == "expected_output":
+ # Hydrate scenarios with generic raw_option data
+ argvalues += get_hydrated_scenarios(
+ [metafunc.cls.raw_option], metafunc.cls.scenarios
+ )
+ ids += [
+ generate_test_name(*args.values)
+ if isinstance(args, ParameterSet)
+ else generate_test_name(*args)
+ for args in argvalues
+ ]
+ elif params[1] == "expected_normalized":
+ argvalues += metafunc.cls.normalized
+ ids += [
+ f"{metafunc.cls.raw_option['type']}-normalize-{scenario[0]}"
+ for scenario in metafunc.cls.normalized
+ ]
+ elif params[1] == "expected_humanized":
+ argvalues += metafunc.cls.humanized
+ ids += [
+ f"{metafunc.cls.raw_option['type']}-normalize-{scenario[0]}"
+ for scenario in metafunc.cls.humanized
+ ]
+
+ metafunc.parametrize(argnames, argvalues, ids=ids)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ Scenario helpers │
+# ╰───────────────────────────────────────────────────────╯
+
+FAIL = YunohostValidationError
+
+
+def nones(
+ *nones, output, raw_option: dict[str, Any] = {}, fail_if_required: bool = True
+) -> list[PartialScenario]:
+ """
+ Returns common scenarios for ~None values.
+ - required and required + as default -> `FAIL`
+ - optional and optional + as default -> `expected_output=None`
+ """
+ return [
+ (none, FAIL if fail_if_required else output, base_raw_option | raw_option) # type: ignore
+ for none in nones
+ for base_raw_option in ({}, {"default": none})
+ ] + [
+ (none, output, base_raw_option | raw_option)
+ for none in nones
+ for base_raw_option in ({"optional": True}, {"optional": True, "default": none})
+ ]
+
+
+def unchanged(*args, raw_option: dict[str, Any] = {}) -> list[PartialScenario]:
+ """
+ Returns a series of params for which output is expected to be the same as its intake
+
+ Example::
+ # expect `"value"` to output as `"value"`, etc.
+ unchanged("value", "yes", "none")
+
+ """
+ return [(arg, arg, raw_option.copy()) for arg in args]
+
+
+def all_as(*args, output, raw_option: dict[str, Any] = {}) -> list[PartialScenario]:
+ """
+ Returns a series of params for which output is expected to be the same single value
+
+ Example::
+ # expect all values to output as `True`
+ all_as("y", "yes", 1, True, output=True)
+ """
+ return [(arg, output, raw_option.copy()) for arg in args]
+
+
+def all_fails(
+ *args, raw_option: dict[str, Any] = {}, error=FAIL
+) -> list[PartialScenario]:
+ """
+ Returns a series of params for which output is expected to be failing with validation error
+ """
+ return [(arg, error, raw_option.copy()) for arg in args]
+
+
+def xpass(*, scenarios: list[Scenario], reason="unknown") -> list[Scenario]:
+ """
+ Return a pytest param for which test should have fail but currently passes.
+ """
+ return [
+ pytest.param(
+ *scenario,
+ marks=pytest.mark.xfail(
+ reason=f"Currently valid but probably shouldn't. details: {reason}."
+ ),
+ )
+ for scenario in scenarios
+ ]
+
+
+def xfail(*, scenarios: list[Scenario], reason="unknown") -> list[Scenario]:
+ """
+ Return a pytest param for which test should have passed but currently fails.
+ """
+ return [
+ pytest.param(
+ *scenario,
+ marks=pytest.mark.xfail(
+ reason=f"Currently invalid but should probably pass. details: {reason}."
+ ),
+ )
+ for scenario in scenarios
+ ]
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ ╶┬╴┌─╴╭─╴╶┬╴╭─╴ │
+# │ │ ├─╴╰─╮ │ ╰─╮ │
+# │ ╵ ╰─╴╶─╯ ╵ ╶─╯ │
+# ╰───────────────────────────────────────────────────────╯
+
+
+def _fill_or_prompt_one_option(raw_option, intake):
+ raw_option = raw_option.copy()
+ id_ = raw_option.pop("id")
+ options = {id_: raw_option}
+ answers = {id_: intake} if intake is not None else {}
+
+ option = ask_questions_and_parse_answers(options, answers)[0]
+
+ return (option, option.value)
+
+
+def _test_value_is_expected_output(value, expected_output):
+ """
+ Properly compares bools and None
+ """
+ if isinstance(expected_output, bool) or expected_output is None:
+ assert value is expected_output
+ else:
+ assert value == expected_output
+
+
+def _test_intake(raw_option, intake, expected_output):
+ option, value = _fill_or_prompt_one_option(raw_option, intake)
+
+ _test_value_is_expected_output(value, expected_output)
+
+
+def _test_intake_may_fail(raw_option, intake, expected_output):
+ if inspect.isclass(expected_output) and issubclass(expected_output, Exception):
+ with pytest.raises(expected_output):
+ _fill_or_prompt_one_option(raw_option, intake)
+ else:
+ _test_intake(raw_option, intake, expected_output)
+
+
+class BaseTest:
+ raw_option: dict[str, Any] = {}
+ prefill: dict[Literal["raw_option", "prefill", "intake"], Any]
+ scenarios: list[Scenario]
+
+ # fmt: off
+ # scenarios = [
+ # *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ # *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+ # *nones(None, "", output=""),
+ # ]
+ # fmt: on
+ # TODO
+ # - pattern (also on Date for example to see if it override the default pattern)
+ # - example
+ # - visible
+ # - redact
+ # - regex
+ # - hooks
+
+ @classmethod
+ def get_raw_option(cls, raw_option={}, **kwargs):
+ base_raw_option = cls.raw_option.copy()
+ base_raw_option.update(**raw_option)
+ base_raw_option.update(**kwargs)
+ return base_raw_option
+
+ @classmethod
+ def _test_basic_attrs(self):
+ raw_option = self.get_raw_option(optional=True)
+ id_ = raw_option["id"]
+ option, value = _fill_or_prompt_one_option(raw_option, None)
+
+ is_special_readonly_option = isinstance(option, DisplayTextQuestion)
+
+ assert isinstance(option, ARGUMENTS_TYPE_PARSERS[raw_option["type"]])
+ assert option.type == raw_option["type"]
+ assert option.name == id_
+ assert option.ask == {"en": id_}
+ assert option.readonly is (True if is_special_readonly_option else False)
+ assert option.visible is None
+ # assert option.bind is None
+
+ if is_special_readonly_option:
+ assert value is None
+
+ return (raw_option, option, value)
+
+ @pytest.mark.usefixtures("patch_no_tty")
+ def test_basic_attrs(self):
+ """
+ Test basic options factories and BaseOption default attributes values.
+ """
+ # Intermediate method since pytest doesn't like tests that returns something.
+ # This allow a test class to call `_test_basic_attrs` then do additional checks
+ self._test_basic_attrs()
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ """
+ Test that assert that moulinette prompt is called with:
+ - `message` with translated string and possible choices list
+ - help` with translated string
+ - `prefill` is the expected string value from a custom default
+ - `is_password` is true for `password`s only
+ - `is_multiline` is true for `text`s only
+ - `autocomplete` is option choices
+
+ Ran only once with `cls.prefill` data
+ """
+ if prefill_data is None:
+ prefill_data = self.prefill
+
+ base_raw_option = prefill_data["raw_option"]
+ prefill = prefill_data["prefill"]
+
+ with patch_prompt("") as prompt:
+ raw_option = self.get_raw_option(
+ raw_option=base_raw_option,
+ ask={"en": "Can i haz question?"},
+ help={"en": "Here's help!"},
+ )
+ option, value = _fill_or_prompt_one_option(raw_option, None)
+
+ expected_message = option.ask["en"]
+
+ if option.choices:
+ choices = (
+ option.choices
+ if isinstance(option.choices, list)
+ else option.choices.keys()
+ )
+ expected_message += f" [{' | '.join(choices)}]"
+ if option.type == "boolean":
+ expected_message += " [yes | no]"
+
+ prompt.assert_called_with(
+ message=expected_message,
+ is_password=option.type == "password",
+ confirm=False, # FIXME no confirm?
+ prefill=prefill,
+ is_multiline=option.type == "text",
+ autocomplete=option.choices or [],
+ help=option.help["en"],
+ )
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ with patch_interface("api"):
+ _test_intake_may_fail(
+ raw_option,
+ intake,
+ expected_output,
+ )
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ DISPLAY_TEXT │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestDisplayText(BaseTest):
+ raw_option = {"type": "display_text", "id": "display_text_id"}
+ prefill = {
+ "raw_option": {},
+ "prefill": " custom default",
+ }
+ # fmt: off
+ scenarios = [
+ (None, None, {"ask": "Some text\na new line"}),
+ (None, None, {"ask": {"en": "Some text\na new line", "fr": "Un peu de texte\nune nouvelle ligne"}}),
+ ]
+ # fmt: on
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ pytest.skip(reason="no prompt for display types")
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ _id = raw_option.pop("id")
+ answers = {_id: intake} if intake is not None else {}
+ options = None
+ with patch_interface("cli"):
+ if inspect.isclass(expected_output) and issubclass(
+ expected_output, Exception
+ ):
+ with pytest.raises(expected_output):
+ ask_questions_and_parse_answers({_id: raw_option}, answers)
+ else:
+ with patch.object(sys, "stdout", new_callable=StringIO) as stdout:
+ options = ask_questions_and_parse_answers(
+ {_id: raw_option}, answers
+ )
+ assert stdout.getvalue() == f"{options[0].ask['en']}\n"
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ MARKDOWN │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestMarkdown(TestDisplayText):
+ raw_option = {"type": "markdown", "id": "markdown_id"}
+ # in cli this option is exactly the same as "display_text", no markdown support for now
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ ALERT │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestAlert(TestDisplayText):
+ raw_option = {"type": "alert", "id": "alert_id"}
+ prefill = {
+ "raw_option": {"ask": " Custom info message"},
+ "prefill": " custom default",
+ }
+ # fmt: off
+ scenarios = [
+ (None, None, {"ask": "Some text\na new line"}),
+ (None, None, {"ask": {"en": "Some text\na new line", "fr": "Un peu de texte\nune nouvelle ligne"}}),
+ *[(None, None, {"ask": "question", "style": style}) for style in ("success", "info", "warning", "danger")],
+ *xpass(scenarios=[
+ (None, None, {"ask": "question", "style": "nimp"}),
+ ], reason="Should fail, wrong style"),
+ ]
+ # fmt: on
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ style = raw_option.get("style", "info")
+ colors = {"danger": "31", "warning": "33", "info": "36", "success": "32"}
+ answers = {"alert_id": intake} if intake is not None else {}
+
+ with patch_interface("cli"):
+ if inspect.isclass(expected_output) and issubclass(
+ expected_output, Exception
+ ):
+ with pytest.raises(expected_output):
+ ask_questions_and_parse_answers(
+ {"display_text_id": raw_option}, answers
+ )
+ else:
+ with patch.object(sys, "stdout", new_callable=StringIO) as stdout:
+ options = ask_questions_and_parse_answers(
+ {"display_text_id": raw_option}, answers
+ )
+ ask = options[0].ask["en"]
+ if style in colors:
+ color = colors[style]
+ title = style.title() + (":" if style != "success" else "!")
+ assert (
+ stdout.getvalue()
+ == f"\x1b[{color}m\x1b[1m{title}\x1b[m {ask}\n"
+ )
+ else:
+ # FIXME should fail
+ stdout.getvalue() == f"{ask}\n"
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ BUTTON │
+# ╰───────────────────────────────────────────────────────╯
+
+
+# TODO
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ STRING │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestString(BaseTest):
+ raw_option = {"type": "string", "id": "string_id"}
+ prefill = {
+ "raw_option": {"default": " custom default"},
+ "prefill": " custom default",
+ }
+ # fmt: off
+ scenarios = [
+ *nones(None, "", output=""),
+ # basic typed values
+ *unchanged(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}), # FIXME should output as str?
+ *unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", raw_option={"optional": True}),
+ *xpass(scenarios=[
+ ([], []),
+ ], reason="Should fail"),
+ # test strip
+ ("value", "value"),
+ ("value\n", "value"),
+ (" \n value\n", "value"),
+ (" \\n value\\n", "\\n value\\n"),
+ (" \tvalue\t", "value"),
+ (r" ##value \n \tvalue\n ", r"##value \n \tvalue\n"),
+ *xpass(scenarios=[
+ ("value\nvalue", "value\nvalue"),
+ (" ##value \n \tvalue\n ", "##value \n \tvalue"),
+ ], reason=r"should fail or without `\n`?"),
+ # readonly
+ *xfail(scenarios=[
+ ("overwrite", "expected value", {"readonly": True, "default": "expected value"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ TEXT │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestText(BaseTest):
+ raw_option = {"type": "text", "id": "text_id"}
+ prefill = {
+ "raw_option": {"default": "some value\nanother line "},
+ "prefill": "some value\nanother line ",
+ }
+ # fmt: off
+ scenarios = [
+ *nones(None, "", output=""),
+ # basic typed values
+ *unchanged(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}), # FIXME should fail or output as str?
+ *unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", raw_option={"optional": True}),
+ *xpass(scenarios=[
+ ([], [])
+ ], reason="Should fail"),
+ ("value", "value"),
+ ("value\n value", "value\n value"),
+ # test no strip
+ *xpass(scenarios=[
+ ("value\n", "value"),
+ (" \n value\n", "value"),
+ (" \\n value\\n", "\\n value\\n"),
+ (" \tvalue\t", "value"),
+ (" ##value \n \tvalue\n ", "##value \n \tvalue"),
+ (r" ##value \n \tvalue\n ", r"##value \n \tvalue\n"),
+ ], reason="Should not be stripped"),
+ # readonly
+ *xfail(scenarios=[
+ ("overwrite", "expected value", {"readonly": True, "default": "expected value"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ PASSWORD │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestPassword(BaseTest):
+ raw_option = {"type": "password", "id": "password_id"}
+ prefill = {
+ "raw_option": {"default": None, "optional": True},
+ "prefill": "",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, raw_option={"optional": True}, error=TypeError), # FIXME those fails with TypeError
+ *all_fails([], ["one"], {}, raw_option={"optional": True}, error=AttributeError), # FIXME those fails with AttributeError
+ *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+ *nones(None, "", output=""),
+ ("s3cr3t!!", FAIL, {"default": "SUPAs3cr3t!!"}), # default is forbidden
+ *xpass(scenarios=[
+ ("s3cr3t!!", "s3cr3t!!", {"example": "SUPAs3cr3t!!"}), # example is forbidden
+ ], reason="Should fail; example is forbidden"),
+ *xpass(scenarios=[
+ (" value \n moarc0mpl1cat3d\n ", "value \n moarc0mpl1cat3d"),
+ (" some_ value", "some_ value"),
+ ], reason="Should output exactly the same"),
+ ("s3cr3t!!", "s3cr3t!!"),
+ ("secret", FAIL),
+ *[("supersecret" + char, FAIL) for char in PasswordQuestion.forbidden_chars], # FIXME maybe add ` \n` to the list?
+ # readonly
+ *xpass(scenarios=[
+ ("s3cr3t!!", "s3cr3t!!", {"readonly": True}),
+ ], reason="Should fail since readonly is forbidden"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ COLOR │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestColor(BaseTest):
+ raw_option = {"type": "color", "id": "color_id"}
+ prefill = {
+ "raw_option": {"default": "#ff0000"},
+ "prefill": "#ff0000",
+ # "intake": "#ff00ff",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+ *nones(None, "", output=""),
+ # custom valid
+ ("#000000", "#000000"),
+ ("#000", "#000"),
+ ("#fe100", "#fe100"),
+ (" #fe100 ", "#fe100"),
+ ("#ABCDEF", "#ABCDEF"),
+ # custom fail
+ *xpass(scenarios=[
+ ("#feaf", "#feaf"),
+ ], reason="Should fail; not a legal color value"),
+ ("000000", FAIL),
+ ("#12", FAIL),
+ ("#gggggg", FAIL),
+ ("#01010101af", FAIL),
+ *xfail(scenarios=[
+ ("red", "#ff0000"),
+ ("yellow", "#ffff00"),
+ ], reason="Should work with pydantic"),
+ # readonly
+ *xfail(scenarios=[
+ ("#ffff00", "#fe100", {"readonly": True, "default": "#fe100"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ NUMBER | RANGE │
+# ╰───────────────────────────────────────────────────────╯
+# Testing only number since "range" is only for webadmin (slider instead of classic intake).
+
+
+class TestNumber(BaseTest):
+ raw_option = {"type": "number", "id": "number_id"}
+ prefill = {
+ "raw_option": {"default": 10},
+ "prefill": "10",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails([], ["one"], {}),
+ *all_fails("none", "_none", "False", "True", "[]", ",", "['one']", "one,two", r"{}", "value"),
+
+ *nones(None, "", output=None),
+ *unchanged(0, 1, -1, 1337),
+ *xpass(scenarios=[(False, False)], reason="should fail or output as `0`"),
+ *xpass(scenarios=[(True, True)], reason="should fail or output as `1`"),
+ *all_as("0", 0, output=0),
+ *all_as("1", 1, output=1),
+ *all_as("1337", 1337, output=1337),
+ *xfail(scenarios=[
+ ("-1", -1)
+ ], reason="should output as `-1` instead of failing"),
+ *all_fails(13.37, "13.37"),
+
+ *unchanged(10, 5000, 10000, raw_option={"min": 10, "max": 10000}),
+ *all_fails(9, 10001, raw_option={"min": 10, "max": 10000}),
+
+ *all_as(None, "", output=0, raw_option={"default": 0}),
+ *all_as(None, "", output=0, raw_option={"default": 0, "optional": True}),
+ (-10, -10, {"default": 10}),
+ (-10, -10, {"default": 10, "optional": True}),
+ # readonly
+ *xfail(scenarios=[
+ (1337, 10000, {"readonly": True, "default": 10000}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+ # FIXME should `step` be some kind of "multiple of"?
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ BOOLEAN │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestBoolean(BaseTest):
+ raw_option = {"type": "boolean", "id": "boolean_id"}
+ prefill = {
+ "raw_option": {"default": True},
+ "prefill": "yes",
+ }
+ # fmt: off
+ truthy_values = (True, 1, "1", "True", "true", "Yes", "yes", "y", "on")
+ falsy_values = (False, 0, "0", "False", "false", "No", "no", "n", "off")
+ scenarios = [
+ *all_as(None, "", output=0),
+ *all_fails("none", "None"), # FIXME should output as `0` (default) like other none values when required?
+ *all_as(None, "", output=0, raw_option={"optional": True}), # FIXME should output as `None`?
+ *all_as("none", "None", output=None, raw_option={"optional": True}),
+ # FIXME even if default is explicity `None|""`, it ends up with class_default `0`
+ *all_as(None, "", output=0, raw_option={"default": None}), # FIXME this should fail, default is `None`
+ *all_as(None, "", output=0, raw_option={"optional": True, "default": None}), # FIXME even if default is explicity None, it ends up with class_default
+ *all_as(None, "", output=0, raw_option={"default": ""}), # FIXME this should fail, default is `""`
+ *all_as(None, "", output=0, raw_option={"optional": True, "default": ""}), # FIXME even if default is explicity None, it ends up with class_default
+ # With "none" behavior is ok
+ *all_fails(None, "", raw_option={"default": "none"}),
+ *all_as(None, "", output=None, raw_option={"optional": True, "default": "none"}),
+ # Unhandled types should fail
+ *all_fails(1337, "1337", "string", [], "[]", ",", "one,two"),
+ *all_fails(1337, "1337", "string", [], "[]", ",", "one,two", {"optional": True}),
+ # Required
+ *all_as(*truthy_values, output=1),
+ *all_as(*falsy_values, output=0),
+ # Optional
+ *all_as(*truthy_values, output=1, raw_option={"optional": True}),
+ *all_as(*falsy_values, output=0, raw_option={"optional": True}),
+ # test values as default, as required option without intake
+ *[(None, 1, {"default": true for true in truthy_values})],
+ *[(None, 0, {"default": false for false in falsy_values})],
+ # custom boolean output
+ ("", "disallow", {"yes": "allow", "no": "disallow"}), # required -> default to False -> `"disallow"`
+ ("n", "disallow", {"yes": "allow", "no": "disallow"}),
+ ("y", "allow", {"yes": "allow", "no": "disallow"}),
+ ("", False, {"yes": True, "no": False}), # required -> default to False -> `False`
+ ("n", False, {"yes": True, "no": False}),
+ ("y", True, {"yes": True, "no": False}),
+ ("", -1, {"yes": 1, "no": -1}), # required -> default to False -> `-1`
+ ("n", -1, {"yes": 1, "no": -1}),
+ ("y", 1, {"yes": 1, "no": -1}),
+ {
+ "raw_options": [
+ {"yes": "no", "no": "yes", "optional": True},
+ {"yes": False, "no": True, "optional": True},
+ {"yes": "0", "no": "1", "optional": True},
+ ],
+ # "no" for "yes" and "yes" for "no" should fail
+ "scenarios": all_fails("", "y", "n", error=AssertionError),
+ },
+ # readonly
+ *xfail(scenarios=[
+ (1, 0, {"readonly": True, "default": 0}),
+ ], reason="Should not be overwritten"),
+ ]
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ DATE │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestDate(BaseTest):
+ raw_option = {"type": "date", "id": "date_id"}
+ prefill = {
+ "raw_option": {"default": "2024-12-29"},
+ "prefill": "2024-12-29",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+ *nones(None, "", output=""),
+ # custom valid
+ ("2070-12-31", "2070-12-31"),
+ ("2024-02-29", "2024-02-29"),
+ *xfail(scenarios=[
+ ("2025-06-15T13:45:30", "2025-06-15"),
+ ("2025-06-15 13:45:30", "2025-06-15")
+ ], reason="iso date repr should be valid and extra data striped"),
+ *xfail(scenarios=[
+ (1749938400, "2025-06-15"),
+ (1749938400.0, "2025-06-15"),
+ ("1749938400", "2025-06-15"),
+ ("1749938400.0", "2025-06-15"),
+ ], reason="timestamp could be an accepted value"),
+ # custom invalid
+ ("29-12-2070", FAIL),
+ ("12-01-10", FAIL),
+ ("2022-02-29", FAIL),
+ # readonly
+ *xfail(scenarios=[
+ ("2070-12-31", "2024-02-29", {"readonly": True, "default": "2024-02-29"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ TIME │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestTime(BaseTest):
+ raw_option = {"type": "time", "id": "time_id"}
+ prefill = {
+ "raw_option": {"default": "12:26"},
+ "prefill": "12:26",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+ *nones(None, "", output=""),
+ # custom valid
+ *unchanged("00:00", "08:00", "12:19", "20:59", "23:59"),
+ ("3:00", "3:00"), # FIXME should fail or output as `"03:00"`?
+ *xfail(scenarios=[
+ ("22:35:05", "22:35"),
+ ("22:35:03.514", "22:35"),
+ ], reason="time as iso format could be valid"),
+ # custom invalid
+ ("24:00", FAIL),
+ ("23:1", FAIL),
+ ("23:005", FAIL),
+ # readonly
+ *xfail(scenarios=[
+ ("00:00", "08:00", {"readonly": True, "default": "08:00"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ EMAIL │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestEmail(BaseTest):
+ raw_option = {"type": "email", "id": "email_id"}
+ prefill = {
+ "raw_option": {"default": "Abc@example.tld"},
+ "prefill": "Abc@example.tld",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ *all_fails("none", "_none", "False", "True", "0", "1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+
+ *nones(None, "", output=""),
+ ("\n Abc@example.tld ", "Abc@example.tld"),
+ # readonly
+ *xfail(scenarios=[
+ ("Abc@example.tld", "admin@ynh.local", {"readonly": True, "default": "admin@ynh.local"}),
+ ], reason="Should not be overwritten"),
+
+ # Next examples are from https://github.com/JoshData/python-email-validator/blob/main/tests/test_syntax.py
+ # valid email values
+ ("Abc@example.tld", "Abc@example.tld"),
+ ("Abc.123@test-example.com", "Abc.123@test-example.com"),
+ ("user+mailbox/department=shipping@example.tld", "user+mailbox/department=shipping@example.tld"),
+ ("伊昭傑@郵件.商務", "伊昭傑@郵件.商務"),
+ ("राम@मोहन.ईन्फो", "राम@मोहन.ईन्फो"),
+ ("юзер@екзампл.ком", "юзер@екзампл.ком"),
+ ("θσερ@εχαμπλε.ψομ", "θσερ@εχαμπλε.ψομ"),
+ ("葉士豪@臺網中心.tw", "葉士豪@臺網中心.tw"),
+ ("jeff@臺網中心.tw", "jeff@臺網中心.tw"),
+ ("葉士豪@臺網中心.台灣", "葉士豪@臺網中心.台灣"),
+ ("jeff葉@臺網中心.tw", "jeff葉@臺網中心.tw"),
+ ("ñoñó@example.tld", "ñoñó@example.tld"),
+ ("甲斐黒川日本@example.tld", "甲斐黒川日本@example.tld"),
+ ("чебурашкаящик-с-апельсинами.рф@example.tld", "чебурашкаящик-с-апельсинами.рф@example.tld"),
+ ("उदाहरण.परीक्ष@domain.with.idn.tld", "उदाहरण.परीक्ष@domain.with.idn.tld"),
+ ("ιωάννης@εεττ.gr", "ιωάννης@εεττ.gr"),
+ # invalid email (Hiding because our current regex is very permissive)
+ # ("my@localhost", FAIL),
+ # ("my@.leadingdot.com", FAIL),
+ # ("my@.leadingfwdot.com", FAIL),
+ # ("my@twodots..com", FAIL),
+ # ("my@twofwdots...com", FAIL),
+ # ("my@trailingdot.com.", FAIL),
+ # ("my@trailingfwdot.com.", FAIL),
+ # ("me@-leadingdash", FAIL),
+ # ("me@-leadingdashfw", FAIL),
+ # ("me@trailingdash-", FAIL),
+ # ("me@trailingdashfw-", FAIL),
+ # ("my@baddash.-.com", FAIL),
+ # ("my@baddash.-a.com", FAIL),
+ # ("my@baddash.b-.com", FAIL),
+ # ("my@baddashfw.-.com", FAIL),
+ # ("my@baddashfw.-a.com", FAIL),
+ # ("my@baddashfw.b-.com", FAIL),
+ # ("my@example.com\n", FAIL),
+ # ("my@example\n.com", FAIL),
+ # ("me@x!", FAIL),
+ # ("me@x ", FAIL),
+ # (".leadingdot@domain.com", FAIL),
+ # ("twodots..here@domain.com", FAIL),
+ # ("trailingdot.@domain.email", FAIL),
+ # ("me@⒈wouldbeinvalid.com", FAIL),
+ ("@example.com", FAIL),
+ # ("\nmy@example.com", FAIL),
+ ("m\ny@example.com", FAIL),
+ ("my\n@example.com", FAIL),
+ # ("11111111112222222222333333333344444444445555555555666666666677777@example.com", FAIL),
+ # ("111111111122222222223333333333444444444455555555556666666666777777@example.com", FAIL),
+ # ("me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444444444455555555556.com", FAIL),
+ # ("me@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com", FAIL),
+ # ("me@中1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555566.com", FAIL),
+ # ("my.long.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333333344444.info", FAIL),
+ # ("my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.11111111112222222222333333.info", FAIL),
+ # ("my.long.address@λ111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info", FAIL),
+ # ("my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.111111111122222222223333333333444.info", FAIL),
+ # ("my.λong.address@1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444444444555555555.6666666666777777777788888888889999999999000000000.1111111111222222222233333333334444.info", FAIL),
+ # ("me@bad-tld-1", FAIL),
+ # ("me@bad.tld-2", FAIL),
+ # ("me@xn--0.tld", FAIL),
+ # ("me@yy--0.tld", FAIL),
+ # ("me@yy--0.tld", FAIL),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ PATH │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestWebPath(BaseTest):
+ raw_option = {"type": "path", "id": "path_id"}
+ prefill = {
+ "raw_option": {"default": "some_path"},
+ "prefill": "some_path",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+
+ *nones(None, "", output=""),
+ # custom valid
+ ("/", "/"),
+ ("/one/two", "/one/two"),
+ *[
+ (v, "/" + v)
+ for v in ("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value")
+ ],
+ ("value\n", "/value"),
+ ("//value", "/value"),
+ ("///value///", "/value"),
+ *xpass(scenarios=[
+ ("value\nvalue", "/value\nvalue"),
+ ("value value", "/value value"),
+ ("value//value", "/value//value"),
+ ], reason="Should fail"),
+ *xpass(scenarios=[
+ ("./here", "/./here"),
+ ("../here", "/../here"),
+ ("/somewhere/../here", "/somewhere/../here"),
+ ], reason="Should fail or flattened"),
+
+ *xpass(scenarios=[
+ ("/one?withquery=ah", "/one?withquery=ah"),
+ ], reason="Should fail or query string removed"),
+ *xpass(scenarios=[
+ ("https://example.com/folder", "/https://example.com/folder")
+ ], reason="Should fail or scheme+domain removed"),
+ # readonly
+ *xfail(scenarios=[
+ ("/overwrite", "/value", {"readonly": True, "default": "/value"}),
+ ], reason="Should not be overwritten"),
+ # FIXME should path have forbidden_chars?
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ URL │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestUrl(BaseTest):
+ raw_option = {"type": "url", "id": "url_id"}
+ prefill = {
+ "raw_option": {"default": "https://domain.tld"},
+ "prefill": "https://domain.tld",
+ }
+ # fmt: off
+ scenarios = [
+ *all_fails(False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {}, raw_option={"optional": True}),
+ *all_fails("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n", raw_option={"optional": True}),
+
+ *nones(None, "", output=""),
+ ("http://some.org/folder/file.txt", "http://some.org/folder/file.txt"),
+ # readonly
+ *xfail(scenarios=[
+ ("https://overwrite.org", "https://example.org", {"readonly": True, "default": "https://example.org"}),
+ ], reason="Should not be overwritten"),
+ # rest is taken from https://github.com/pydantic/pydantic/blob/main/tests/test_networks.py
+ # valid
+ *unchanged(
+ # Those are valid but not sure how they will output with pydantic
+ 'http://example.org',
+ 'http://test',
+ 'http://localhost',
+ 'https://example.org/whatever/next/',
+ 'https://example.org',
+ 'http://localhost',
+ 'http://localhost/',
+ 'http://localhost:8000',
+ 'http://localhost:8000/',
+ 'https://foo_bar.example.com/',
+ 'http://example.co.jp',
+ 'http://www.example.com/a%C2%B1b',
+ 'http://www.example.com/~username/',
+ 'http://info.example.com?fred',
+ 'http://info.example.com/?fred',
+ 'http://xn--mgbh0fb.xn--kgbechtv/',
+ 'http://example.com/blue/red%3Fand+green',
+ 'http://www.example.com/?array%5Bkey%5D=value',
+ 'http://xn--rsum-bpad.example.org/',
+ 'http://123.45.67.8/',
+ 'http://123.45.67.8:8329/',
+ 'http://[2001:db8::ff00:42]:8329',
+ 'http://[2001::1]:8329',
+ 'http://[2001:db8::1]/',
+ 'http://www.example.com:8000/foo',
+ 'http://www.cwi.nl:80/%7Eguido/Python.html',
+ 'https://www.python.org/путь',
+ 'http://андрей@example.com',
+ 'https://exam_ple.com/',
+ 'http://twitter.com/@handle/',
+ 'http://11.11.11.11.example.com/action',
+ 'http://abc.11.11.11.11.example.com/action',
+ 'http://example#',
+ 'http://example/#',
+ 'http://example/#fragment',
+ 'http://example/?#',
+ 'http://example.org/path#',
+ 'http://example.org/path#fragment',
+ 'http://example.org/path?query#',
+ 'http://example.org/path?query#fragment',
+ ),
+ # Pydantic default parsing add a final `/`
+ ('https://foo_bar.example.com/', 'https://foo_bar.example.com/'),
+ ('https://exam_ple.com/', 'https://exam_ple.com/'),
+ *xfail(scenarios=[
+ (' https://www.example.com \n', 'https://www.example.com/'),
+ ('HTTP://EXAMPLE.ORG', 'http://example.org/'),
+ ('https://example.org', 'https://example.org/'),
+ ('https://example.org?a=1&b=2', 'https://example.org/?a=1&b=2'),
+ ('https://example.org#a=3;b=3', 'https://example.org/#a=3;b=3'),
+ ('https://example.xn--p1ai', 'https://example.xn--p1ai/'),
+ ('https://example.xn--vermgensberatung-pwb', 'https://example.xn--vermgensberatung-pwb/'),
+ ('https://example.xn--zfr164b', 'https://example.xn--zfr164b/'),
+ ], reason="pydantic default behavior would append a final `/`"),
+
+ # invalid
+ *all_fails(
+ 'ftp://example.com/',
+ "$https://example.org",
+ "../icons/logo.gif",
+ "abc",
+ "..",
+ "/",
+ "+http://example.com/",
+ "ht*tp://example.com/",
+ ),
+ *xpass(scenarios=[
+ ("http:///", "http:///"),
+ ("http://??", "http://??"),
+ ("https://example.org more", "https://example.org more"),
+ ("http://2001:db8::ff00:42:8329", "http://2001:db8::ff00:42:8329"),
+ ("http://[192.168.1.1]:8329", "http://[192.168.1.1]:8329"),
+ ("http://example.com:99999", "http://example.com:99999"),
+ ], reason="Should fail"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ FILE │
+# ╰───────────────────────────────────────────────────────╯
+
+
+@pytest.fixture
+def file_clean():
+ FileQuestion.clean_upload_dirs()
+ yield
+ FileQuestion.clean_upload_dirs()
+
+
+@contextmanager
+def patch_file_cli(intake):
+ upload_dir = tempfile.mkdtemp(prefix="ynh_test_option_file")
+ _, filename = tempfile.mkstemp(dir=upload_dir)
+ with open(filename, "w") as f:
+ f.write(intake)
+
+ yield filename
+ os.system(f"rm -f {filename}")
+
+
+@contextmanager
+def patch_file_api(intake):
+ from base64 import b64encode
+
+ with patch_interface("api"):
+ yield b64encode(intake.encode())
+
+
+def _test_file_intake_may_fail(raw_option, intake, expected_output):
+ if inspect.isclass(expected_output) and issubclass(expected_output, Exception):
+ with pytest.raises(expected_output):
+ _fill_or_prompt_one_option(raw_option, intake)
+
+ option, value = _fill_or_prompt_one_option(raw_option, intake)
+
+ # The file is supposed to be copied somewhere else
+ assert value != intake
+ assert value.startswith("/tmp/ynh_filequestion_")
+ assert os.path.exists(value)
+ with open(value) as f:
+ assert f.read() == expected_output
+
+ FileQuestion.clean_upload_dirs()
+
+ assert not os.path.exists(value)
+
+
+file_content1 = "helloworld"
+file_content2 = """
+{
+ "testy": true,
+ "test": ["one"]
+}
+"""
+
+
+class TestFile(BaseTest):
+ raw_option = {"type": "file", "id": "file_id"}
+ # Prefill data is generated in `cls.test_options_prompted_with_ask_help`
+ # fmt: off
+ scenarios = [
+ *nones(None, "", output=""),
+ *unchanged(file_content1, file_content2),
+ # other type checks are done in `test_wrong_intake`
+ ]
+ # fmt: on
+ # TODO test readonly
+ # TODO test accept
+
+ @pytest.mark.usefixtures("patch_no_tty")
+ def test_basic_attrs(self):
+ raw_option, option, value = self._test_basic_attrs()
+
+ accept = raw_option.get("accept", "") # accept default
+ assert option.accept == accept
+
+ def test_options_prompted_with_ask_help(self):
+ with patch_file_cli(file_content1) as default_filename:
+ super().test_options_prompted_with_ask_help(
+ prefill_data={
+ "raw_option": {
+ "default": default_filename,
+ },
+ "prefill": default_filename,
+ }
+ )
+
+ @pytest.mark.usefixtures("file_clean")
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ if intake in (None, ""):
+ with patch_prompt(intake):
+ _test_intake_may_fail(raw_option, None, expected_output)
+ with patch_isatty(False):
+ _test_intake_may_fail(raw_option, intake, expected_output)
+ else:
+ with patch_file_cli(intake) as filename:
+ with patch_prompt(filename):
+ _test_file_intake_may_fail(raw_option, None, expected_output)
+ with patch_file_api(intake) as b64content:
+ with patch_isatty(False):
+ _test_file_intake_may_fail(raw_option, b64content, expected_output)
+
+ @pytest.mark.parametrize(
+ "path",
+ [
+ "/tmp/inexistant_file.txt",
+ "/tmp",
+ "/tmp/",
+ ],
+ )
+ def test_wrong_cli_filename(self, path):
+ with patch_prompt(path):
+ with pytest.raises(YunohostValidationError):
+ _fill_or_prompt_one_option(self.raw_option, None)
+
+ @pytest.mark.parametrize(
+ "intake",
+ [
+ # fmt: off
+ False, True, 0, 1, -1, 1337, 13.37, [], ["one"], {},
+ "none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", ",", "['one']", "one,two", r"{}", "value", "value\n"
+ # fmt: on
+ ],
+ )
+ def test_wrong_intake(self, intake):
+ with pytest.raises(YunohostValidationError):
+ with patch_prompt(intake):
+ _fill_or_prompt_one_option(self.raw_option, None)
+ with patch_isatty(False):
+ _fill_or_prompt_one_option(self.raw_option, intake)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ SELECT │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestSelect(BaseTest):
+ raw_option = {"type": "select", "id": "select_id"}
+ prefill = {
+ "raw_option": {"default": "one", "choices": ["one", "two"]},
+ "prefill": "one",
+ }
+ # fmt: off
+ scenarios = [
+ {
+ # ["one", "two"]
+ "raw_options": [
+ {"choices": ["one", "two"]},
+ {"choices": {"one": "verbose one", "two": "verbose two"}},
+ ],
+ "scenarios": [
+ *nones(None, "", output=""),
+ *unchanged("one", "two"),
+ ("three", FAIL),
+ ]
+ },
+ # custom bash style list as choices (only strings for now)
+ ("one", "one", {"choices": "one,two"}),
+ {
+ # [-1, 0, 1]
+ "raw_options": [
+ {"choices": [-1, 0, 1, 10]},
+ {"choices": {-1: "verbose -one", 0: "verbose zero", 1: "verbose one", 10: "verbose ten"}},
+ ],
+ "scenarios": [
+ *nones(None, "", output=""),
+ *unchanged(-1, 0, 1, 10),
+ *xfail(scenarios=[
+ ("-1", -1),
+ ("0", 0),
+ ("1", 1),
+ ("10", 10),
+ ], reason="str -> int not handled"),
+ *all_fails("100", 100),
+ ]
+ },
+ # [True, False, None]
+ *unchanged(True, False, raw_option={"choices": [True, False, None]}), # FIXME we should probably forbid None in choices
+ (None, FAIL, {"choices": [True, False, None]}),
+ {
+ # mixed types
+ "raw_options": [{"choices": ["one", 2, True]}],
+ "scenarios": [
+ *xpass(scenarios=[
+ ("one", "one"),
+ (2, 2),
+ (True, True),
+ ], reason="mixed choices, should fail"),
+ *all_fails("2", "True", "y"),
+ ]
+ },
+ {
+ "raw_options": [{"choices": ""}, {"choices": []}],
+ "scenarios": [
+ # FIXME those should fail at option level (wrong default, dev error)
+ *all_fails(None, ""),
+ *xpass(scenarios=[
+ ("", "", {"optional": True}),
+ (None, "", {"optional": True}),
+ ], reason="empty choices, should fail at option instantiation"),
+ ]
+ },
+ # readonly
+ *xfail(scenarios=[
+ ("one", "two", {"readonly": True, "choices": ["one", "two"], "default": "two"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ TAGS │
+# ╰───────────────────────────────────────────────────────╯
+
+
+class TestTags(BaseTest):
+ raw_option = {"type": "tags", "id": "tags_id"}
+ prefill = {
+ "raw_option": {"default": ["one", "two"]},
+ "prefill": "one,two",
+ }
+ # fmt: off
+ scenarios = [
+ *nones(None, [], "", output=""),
+ # FIXME `","` could be considered a none value which kinda already is since it fail when required
+ (",", FAIL),
+ *xpass(scenarios=[
+ (",", ",", {"optional": True})
+ ], reason="Should output as `''`? ie: None"),
+ {
+ "raw_options": [
+ {},
+ {"choices": ["one", "two"]}
+ ],
+ "scenarios": [
+ *unchanged("one", "one,two"),
+ (["one"], "one"),
+ (["one", "two"], "one,two"),
+ ]
+ },
+ ("three", FAIL, {"choices": ["one", "two"]}),
+ *unchanged("none", "_none", "False", "True", "0", "1", "-1", "1337", "13.37", "[]", "['one']", "one,two", r"{}", "value"),
+ (" value\n", "value"),
+ ([False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}], "False,True,-1,0,1,1337,13.37,[],['one'],{}"),
+ *(([t], str(t)) for t in (False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {})),
+ # basic types (not in a list) should fail
+ *all_fails(True, False, -1, 0, 1, 1337, 13.37, {}),
+ # Mixed choices should fail
+ ([False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}], FAIL, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
+ ("False,True,-1,0,1,1337,13.37,[],['one'],{}", FAIL, {"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
+ *all_fails(*([t] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
+ *all_fails(*([str(t)] for t in [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]), raw_option={"choices": [False, True, -1, 0, 1, 1337, 13.37, [], ["one"], {}]}),
+ # readonly
+ *xfail(scenarios=[
+ ("one", "one,two", {"readonly": True, "choices": ["one", "two"], "default": "one,two"}),
+ ], reason="Should not be overwritten"),
+ ]
+ # fmt: on
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ DOMAIN │
+# ╰───────────────────────────────────────────────────────╯
+
+main_domain = "ynh.local"
+domains1 = ["ynh.local"]
+domains2 = ["another.org", "ynh.local", "yet.another.org"]
+
+
+@contextmanager
+def patch_domains(*, domains, main_domain):
+ """
+ Data mocking for DomainOption:
+ - yunohost.domain.domain_list
+ """
+ with patch.object(
+ domain,
+ "domain_list",
+ return_value={"domains": domains, "main": main_domain},
+ ), patch.object(domain, "_get_maindomain", return_value=main_domain):
+ yield
+
+
+class TestDomain(BaseTest):
+ raw_option = {"type": "domain", "id": "domain_id"}
+ prefill = {
+ "raw_option": {
+ "default": None,
+ },
+ "prefill": main_domain,
+ }
+ # fmt: off
+ scenarios = [
+ # Probably not needed to test common types since those are not available as choices
+ # Also no scenarios with no domains since it should not be possible
+ {
+ "data": [{"main_domain": domains1[0], "domains": domains1}],
+ "scenarios": [
+ *nones(None, "", output=domains1[0], fail_if_required=False),
+ (domains1[0], domains1[0], {}),
+ ("doesnt_exist.pouet", FAIL, {}),
+ ("fake.com", FAIL, {"choices": ["fake.com"]}),
+ # readonly
+ *xpass(scenarios=[
+ (domains1[0], domains1[0], {"readonly": True}),
+ ], reason="Should fail since readonly is forbidden"),
+ ]
+ },
+ {
+ "data": [{"main_domain": domains2[1], "domains": domains2}],
+ "scenarios": [
+ *nones(None, "", output=domains2[1], fail_if_required=False),
+ (domains2[1], domains2[1], {}),
+ (domains2[0], domains2[0], {}),
+ ("doesnt_exist.pouet", FAIL, {}),
+ ("fake.com", FAIL, {"choices": ["fake.com"]}),
+ ]
+ },
+
+ ]
+ # fmt: on
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ with patch_domains(**data):
+ super().test_scenarios(intake, expected_output, raw_option, data)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ APP │
+# ╰───────────────────────────────────────────────────────╯
+
+installed_webapp = {
+ "is_webapp": True,
+ "is_default": True,
+ "label": "My webapp",
+ "id": "my_webapp",
+ "domain_path": "/ynh-dev",
+}
+installed_non_webapp = {
+ "is_webapp": False,
+ "is_default": False,
+ "label": "My non webapp",
+ "id": "my_non_webapp",
+}
+
+
+@contextmanager
+def patch_apps(*, apps):
+ """
+ Data mocking for AppOption:
+ - yunohost.app.app_list
+ """
+ with patch.object(app, "app_list", return_value={"apps": apps}):
+ yield
+
+
+class TestApp(BaseTest):
+ raw_option = {"type": "app", "id": "app_id"}
+ # fmt: off
+ scenarios = [
+ # Probably not needed to test common types since those are not available as choices
+ {
+ "data": [
+ {"apps": []},
+ {"apps": [installed_webapp]},
+ {"apps": [installed_webapp, installed_non_webapp]},
+ ],
+ "scenarios": [
+ # FIXME there are currently 3 different nones (`None`, `""` and `_none`), choose one?
+ *nones(None, output=None), # FIXME Should return chosen none?
+ *nones("", output=""), # FIXME Should return chosen none?
+ *xpass(scenarios=[
+ ("_none", "_none"),
+ ("_none", "_none", {"default": "_none"}),
+ ], reason="should fail; is required"),
+ *xpass(scenarios=[
+ ("_none", "_none", {"optional": True}),
+ ("_none", "_none", {"optional": True, "default": "_none"})
+ ], reason="Should output chosen none value"),
+ ("fake_app", FAIL),
+ ("fake_app", FAIL, {"choices": ["fake_app"]}),
+ ]
+ },
+ {
+ "data": [
+ {"apps": [installed_webapp]},
+ {"apps": [installed_webapp, installed_non_webapp]},
+ ],
+ "scenarios": [
+ (installed_webapp["id"], installed_webapp["id"]),
+ (installed_webapp["id"], installed_webapp["id"], {"filter": "is_webapp"}),
+ (installed_webapp["id"], FAIL, {"filter": "is_webapp == false"}),
+ (installed_webapp["id"], FAIL, {"filter": "id != 'my_webapp'"}),
+ (None, None, {"filter": "id == 'fake_app'", "optional": True}),
+ ]
+ },
+ {
+ "data": [{"apps": [installed_webapp, installed_non_webapp]}],
+ "scenarios": [
+ (installed_non_webapp["id"], installed_non_webapp["id"]),
+ (installed_non_webapp["id"], FAIL, {"filter": "is_webapp"}),
+ # readonly
+ *xpass(scenarios=[
+ (installed_non_webapp["id"], installed_non_webapp["id"], {"readonly": True}),
+ ], reason="Should fail since readonly is forbidden"),
+ ]
+ },
+ ]
+ # fmt: on
+
+ @pytest.mark.usefixtures("patch_no_tty")
+ def test_basic_attrs(self):
+ with patch_apps(apps=[]):
+ raw_option, option, value = self._test_basic_attrs()
+
+ assert option.choices == {"_none": "---"}
+ assert option.filter is None
+
+ with patch_apps(apps=[installed_webapp, installed_non_webapp]):
+ raw_option, option, value = self._test_basic_attrs()
+
+ assert option.choices == {
+ "_none": "---",
+ "my_webapp": "My webapp (/ynh-dev)",
+ "my_non_webapp": "My non webapp (my_non_webapp)",
+ }
+ assert option.filter is None
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ with patch_apps(apps=[installed_webapp, installed_non_webapp]):
+ super().test_options_prompted_with_ask_help(
+ prefill_data={
+ "raw_option": {"default": installed_webapp["id"]},
+ "prefill": installed_webapp["id"],
+ }
+ )
+ super().test_options_prompted_with_ask_help(
+ prefill_data={"raw_option": {"optional": True}, "prefill": ""}
+ )
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ with patch_apps(**data):
+ super().test_scenarios(intake, expected_output, raw_option, data)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ USER │
+# ╰───────────────────────────────────────────────────────╯
+
+admin_username = "admin_user"
+admin_user = {
+ "ssh_allowed": False,
+ "username": admin_username,
+ "mailbox-quota": "0",
+ "mail": "a@ynh.local",
+ "mail-aliases": [f"root@{main_domain}"], # Faking "admin"
+ "fullname": "john doe",
+ "group": [],
+}
+regular_username = "normal_user"
+regular_user = {
+ "ssh_allowed": False,
+ "username": regular_username,
+ "mailbox-quota": "0",
+ "mail": "z@ynh.local",
+ "fullname": "john doe",
+ "group": [],
+}
+
+
+@contextmanager
+def patch_users(
+ *,
+ users,
+ admin_username,
+ main_domain,
+):
+ """
+ Data mocking for UserOption:
+ - yunohost.user.user_list
+ - yunohost.user.user_info
+ - yunohost.domain._get_maindomain
+ """
+ admin_info = next(
+ (user for user in users.values() if user["username"] == admin_username),
+ {"mail-aliases": []},
+ )
+ with patch.object(user, "user_list", return_value={"users": users}), patch.object(
+ user,
+ "user_info",
+ return_value=admin_info, # Faking admin user
+ ), patch.object(domain, "_get_maindomain", return_value=main_domain):
+ yield
+
+
+class TestUser(BaseTest):
+ raw_option = {"type": "user", "id": "user_id"}
+ # fmt: off
+ scenarios = [
+ # No tests for empty users since it should not happens
+ {
+ "data": [
+ {"users": {admin_username: admin_user}, "admin_username": admin_username, "main_domain": main_domain},
+ {"users": {admin_username: admin_user, regular_username: regular_user}, "admin_username": admin_username, "main_domain": main_domain},
+ ],
+ "scenarios": [
+ # FIXME User option is not really nullable, even if optional
+ *nones(None, "", output=admin_username, fail_if_required=False),
+ ("fake_user", FAIL),
+ ("fake_user", FAIL, {"choices": ["fake_user"]}),
+ ]
+ },
+ {
+ "data": [
+ {"users": {admin_username: admin_user, regular_username: regular_user}, "admin_username": admin_username, "main_domain": main_domain},
+ ],
+ "scenarios": [
+ *xpass(scenarios=[
+ ("", regular_username, {"default": regular_username})
+ ], reason="Should throw 'no default allowed'"),
+ # readonly
+ *xpass(scenarios=[
+ (admin_username, admin_username, {"readonly": True}),
+ ], reason="Should fail since readonly is forbidden"),
+ ]
+ },
+ ]
+ # fmt: on
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ with patch_users(
+ users={admin_username: admin_user, regular_username: regular_user},
+ admin_username=admin_username,
+ main_domain=main_domain,
+ ):
+ super().test_options_prompted_with_ask_help(
+ prefill_data={"raw_option": {}, "prefill": admin_username}
+ )
+ # FIXME This should fail, not allowed to set a default
+ super().test_options_prompted_with_ask_help(
+ prefill_data={
+ "raw_option": {"default": regular_username},
+ "prefill": regular_username,
+ }
+ )
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ with patch_users(**data):
+ super().test_scenarios(intake, expected_output, raw_option, data)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ GROUP │
+# ╰───────────────────────────────────────────────────────╯
+
+groups1 = ["all_users", "visitors", "admins"]
+groups2 = ["all_users", "visitors", "admins", "custom_group"]
+
+
+@contextmanager
+def patch_groups(*, groups):
+ """
+ Data mocking for GroupOption:
+ - yunohost.user.user_group_list
+ """
+ with patch.object(user, "user_group_list", return_value={"groups": groups}):
+ yield
+
+
+class TestGroup(BaseTest):
+ raw_option = {"type": "group", "id": "group_id"}
+ # fmt: off
+ scenarios = [
+ # No tests for empty groups since it should not happens
+ {
+ "data": [
+ {"groups": groups1},
+ {"groups": groups2},
+ ],
+ "scenarios": [
+ # FIXME Group option is not really nullable, even if optional
+ *nones(None, "", output="all_users", fail_if_required=False),
+ ("admins", "admins"),
+ ("fake_group", FAIL),
+ ("fake_group", FAIL, {"choices": ["fake_group"]}),
+ ]
+ },
+ {
+ "data": [
+ {"groups": groups2},
+ ],
+ "scenarios": [
+ ("custom_group", "custom_group"),
+ *all_as("", None, output="visitors", raw_option={"default": "visitors"}),
+ *xpass(scenarios=[
+ ("", "custom_group", {"default": "custom_group"}),
+ ], reason="Should throw 'default must be in (None, 'all_users', 'visitors', 'admins')"),
+ # readonly
+ *xpass(scenarios=[
+ ("admins", "admins", {"readonly": True}),
+ ], reason="Should fail since readonly is forbidden"),
+ ]
+ },
+ ]
+ # fmt: on
+
+ def test_options_prompted_with_ask_help(self, prefill_data=None):
+ with patch_groups(groups=groups2):
+ super().test_options_prompted_with_ask_help(
+ prefill_data={"raw_option": {}, "prefill": "all_users"}
+ )
+ super().test_options_prompted_with_ask_help(
+ prefill_data={
+ "raw_option": {"default": "admins"},
+ "prefill": "admins",
+ }
+ )
+ # FIXME This should fail, not allowed to set a default which is not a default group
+ super().test_options_prompted_with_ask_help(
+ prefill_data={
+ "raw_option": {"default": "custom_group"},
+ "prefill": "custom_group",
+ }
+ )
+
+ def test_scenarios(self, intake, expected_output, raw_option, data):
+ with patch_groups(**data):
+ super().test_scenarios(intake, expected_output, raw_option, data)
+
+
+# ╭───────────────────────────────────────────────────────╮
+# │ MULTIPLE │
+# ╰───────────────────────────────────────────────────────╯
+
+
+@pytest.fixture
+def patch_entities():
+ with patch_domains(domains=domains2, main_domain=main_domain), patch_apps(
+ apps=[installed_webapp, installed_non_webapp]
+ ), patch_users(
+ users={admin_username: admin_user, regular_username: regular_user},
+ admin_username=admin_username,
+ main_domain=main_domain,
+ ), patch_groups(
+ groups=groups2
+ ):
+ yield
+
+
+def test_options_empty():
ask_questions_and_parse_answers({}, {}) == []
-def test_question_string():
-
- questions = {
- "some_string": {
- "type": "string",
- }
+@pytest.mark.usefixtures("patch_entities", "file_clean")
+def test_options_query_string():
+ raw_options = {
+ "string_id": {"type": "string"},
+ "text_id": {"type": "text"},
+ "password_id": {"type": "password"},
+ "color_id": {"type": "color"},
+ "number_id": {"type": "number"},
+ "boolean_id": {"type": "boolean"},
+ "date_id": {"type": "date"},
+ "time_id": {"type": "time"},
+ "email_id": {"type": "email"},
+ "path_id": {"type": "path"},
+ "url_id": {"type": "url"},
+ "file_id": {"type": "file"},
+ "select_id": {"type": "select", "choices": ["one", "two"]},
+ "tags_id": {"type": "tags", "choices": ["one", "two"]},
+ "domain_id": {"type": "domain"},
+ "app_id": {"type": "app"},
+ "user_id": {"type": "user"},
+ "group_id": {"type": "group"},
}
- answers = {"some_string": "some_value"}
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_from_query_string():
-
- questions = {
- "some_string": {
- "type": "string",
- }
+ results = {
+ "string_id": "string",
+ "text_id": "text\ntext",
+ "password_id": "sUpRSCRT",
+ "color_id": "#ffff00",
+ "number_id": 10,
+ "boolean_id": 1,
+ "date_id": "2030-03-06",
+ "time_id": "20:55",
+ "email_id": "coucou@ynh.local",
+ "path_id": "/ynh-dev",
+ "url_id": "https://yunohost.org",
+ "file_id": file_content1,
+ "select_id": "one",
+ "tags_id": "one,two",
+ "domain_id": main_domain,
+ "app_id": installed_webapp["id"],
+ "user_id": regular_username,
+ "group_id": "admins",
}
- answers = "foo=bar&some_string=some_value&lorem=ipsum"
- out = ask_questions_and_parse_answers(questions, answers)[0]
+ @contextmanager
+ def patch_query_string(file_repr):
+ yield (
+ "string_id= string"
+ "&text_id=text\ntext"
+ "&password_id=sUpRSCRT"
+ "&color_id=#ffff00"
+ "&number_id=10"
+ "&boolean_id=y"
+ "&date_id=2030-03-06"
+ "&time_id=20:55"
+ "&email_id=coucou@ynh.local"
+ "&path_id=ynh-dev/"
+ "&url_id=https://yunohost.org"
+ f"&file_id={file_repr}"
+ "&select_id=one"
+ "&tags_id=one,two"
+ # FIXME We can't test with parse.qs for now, next syntax is available only with config panels
+ # "&tags_id=one"
+ # "&tags_id=two"
+ f"&domain_id={main_domain}"
+ f"&app_id={installed_webapp['id']}"
+ f"&user_id={regular_username}"
+ "&group_id=admins"
+ # not defined extra values are silently ignored
+ "&fake_id=fake_value"
+ )
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
+ def _assert_correct_values(options, raw_options):
+ form = {option.name: option.value for option in options}
+
+ for k, v in results.items():
+ if k == "file_id":
+ assert os.path.exists(form["file_id"]) and os.path.isfile(
+ form["file_id"]
+ )
+ with open(form["file_id"], "r") as f:
+ assert f.read() == file_content1
+ else:
+ assert form[k] == results[k]
+
+ assert len(options) == len(raw_options.keys())
+ assert "fake_id" not in form
+
+ with patch_interface("api"), patch_file_api(file_content1) as b64content:
+ with patch_query_string(b64content.decode("utf-8")) as query_string:
+ options = ask_questions_and_parse_answers(raw_options, query_string)
+ _assert_correct_values(options, raw_options)
+
+ with patch_interface("cli"), patch_file_cli(file_content1) as filepath:
+ with patch_query_string(filepath) as query_string:
+ options = ask_questions_and_parse_answers(raw_options, query_string)
+ _assert_correct_values(options, raw_options)
def test_question_string_default_type():
@@ -91,179 +1985,6 @@ def test_question_string_default_type():
assert out.value == "some_value"
-def test_question_string_no_input():
- questions = {"some_string": {}}
- answers = {}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_string_input():
- questions = {
- "some_string": {
- "ask": "some question",
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_input_no_ask():
- questions = {"some_string": {}}
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_no_input_optional():
- questions = {"some_string": {"optional": True}}
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == ""
-
-
-def test_question_string_optional_with_input():
- questions = {
- "some_string": {
- "ask": "some question",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_optional_with_empty_input():
- questions = {
- "some_string": {
- "ask": "some question",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=""), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == ""
-
-
-def test_question_string_optional_with_input_without_ask():
- questions = {
- "some_string": {
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_no_input_default():
- questions = {
- "some_string": {
- "ask": "some question",
- "default": "some_value",
- }
- }
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_string"
- assert out.type == "string"
- assert out.value == "some_value"
-
-
-def test_question_string_input_test_ask():
- ask_text = "some question"
- questions = {
- "some_string": {
- "ask": ask_text,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill="",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
-def test_question_string_input_test_ask_with_default():
- ask_text = "some question"
- default_text = "some example"
- questions = {
- "some_string": {
- "ask": ask_text,
- "default": default_text,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill=default_text,
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
@pytest.mark.skip # we should do something with this example
def test_question_string_input_test_ask_with_example():
ask_text = "some question"
@@ -284,26 +2005,6 @@ def test_question_string_input_test_ask_with_example():
assert example_text in prompt.call_args[1]["message"]
-@pytest.mark.skip # we should do something with this help
-def test_question_string_input_test_ask_with_help():
- ask_text = "some question"
- help_text = "some_help"
- questions = {
- "some_string": {
- "ask": ask_text,
- "help": help_text,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- assert ask_text in prompt.call_args[1]["message"]
- assert help_text in prompt.call_args[1]["message"]
-
-
def test_question_string_with_choice():
questions = {"some_string": {"type": "string", "choices": ["fr", "en"]}}
answers = {"some_string": "fr"}
@@ -373,210 +2074,6 @@ def test_question_string_with_choice_default():
assert out.value == "en"
-def test_question_password():
- questions = {
- "some_password": {
- "type": "password",
- }
- }
- answers = {"some_password": "some_value"}
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == "some_value"
-
-
-def test_question_password_no_input():
- questions = {
- "some_password": {
- "type": "password",
- }
- }
- answers = {}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_password_input():
- questions = {
- "some_password": {
- "type": "password",
- "ask": "some question",
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == "some_value"
-
-
-def test_question_password_input_no_ask():
- questions = {
- "some_password": {
- "type": "password",
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == "some_value"
-
-
-def test_question_password_no_input_optional():
- questions = {
- "some_password": {
- "type": "password",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == ""
-
- questions = {"some_password": {"type": "password", "optional": True, "default": ""}}
-
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == ""
-
-
-def test_question_password_optional_with_input():
- questions = {
- "some_password": {
- "ask": "some question",
- "type": "password",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == "some_value"
-
-
-def test_question_password_optional_with_empty_input():
- questions = {
- "some_password": {
- "ask": "some question",
- "type": "password",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=""), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == ""
-
-
-def test_question_password_optional_with_input_without_ask():
- questions = {
- "some_password": {
- "type": "password",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_password"
- assert out.type == "password"
- assert out.value == "some_value"
-
-
-def test_question_password_no_input_default():
- questions = {
- "some_password": {
- "type": "password",
- "ask": "some question",
- "default": "some_value",
- }
- }
- answers = {}
-
- # no default for password!
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-@pytest.mark.skip # this should raises
-def test_question_password_no_input_example():
- questions = {
- "some_password": {
- "type": "password",
- "ask": "some question",
- "example": "some_value",
- }
- }
- answers = {"some_password": "some_value"}
-
- # no example for password!
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_password_input_test_ask():
- ask_text = "some question"
- questions = {
- "some_password": {
- "type": "password",
- "ask": ask_text,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=True,
- confirm=False,
- prefill="",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
@pytest.mark.skip # we should do something with this example
def test_question_password_input_test_ask_with_example():
ask_text = "some question"
@@ -598,284 +2095,6 @@ def test_question_password_input_test_ask_with_example():
assert example_text in prompt.call_args[1]["message"]
-@pytest.mark.skip # we should do something with this help
-def test_question_password_input_test_ask_with_help():
- ask_text = "some question"
- help_text = "some_help"
- questions = {
- "some_password": {
- "type": "password",
- "ask": ask_text,
- "help": help_text,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- assert ask_text in prompt.call_args[1]["message"]
- assert help_text in prompt.call_args[1]["message"]
-
-
-def test_question_password_bad_chars():
- questions = {
- "some_password": {
- "type": "password",
- "ask": "some question",
- "example": "some_value",
- }
- }
-
- for i in PasswordQuestion.forbidden_chars:
- with pytest.raises(YunohostError), patch.object(
- os, "isatty", return_value=False
- ):
- ask_questions_and_parse_answers(questions, {"some_password": i * 8})
-
-
-def test_question_password_strong_enough():
- questions = {
- "some_password": {
- "type": "password",
- "ask": "some question",
- "example": "some_value",
- }
- }
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- # too short
- ask_questions_and_parse_answers(questions, {"some_password": "a"})
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, {"some_password": "password"})
-
-
-def test_question_password_optional_strong_enough():
- questions = {
- "some_password": {
- "ask": "some question",
- "type": "password",
- "optional": True,
- }
- }
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- # too short
- ask_questions_and_parse_answers(questions, {"some_password": "a"})
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, {"some_password": "password"})
-
-
-def test_question_path():
- questions = {
- "some_path": {
- "type": "path",
- }
- }
- answers = {"some_path": "/some_value"}
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_no_input():
- questions = {
- "some_path": {
- "type": "path",
- }
- }
- answers = {}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_path_input():
- questions = {
- "some_path": {
- "type": "path",
- "ask": "some question",
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_input_no_ask():
- questions = {
- "some_path": {
- "type": "path",
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_no_input_optional():
- questions = {
- "some_path": {
- "type": "path",
- "optional": True,
- }
- }
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == ""
-
-
-def test_question_path_optional_with_input():
- questions = {
- "some_path": {
- "ask": "some question",
- "type": "path",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_optional_with_empty_input():
- questions = {
- "some_path": {
- "ask": "some question",
- "type": "path",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=""), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == ""
-
-
-def test_question_path_optional_with_input_without_ask():
- questions = {
- "some_path": {
- "type": "path",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="/some_value"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_no_input_default():
- questions = {
- "some_path": {
- "ask": "some question",
- "type": "path",
- "default": "some_value",
- }
- }
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_path"
- assert out.type == "path"
- assert out.value == "/some_value"
-
-
-def test_question_path_input_test_ask():
- ask_text = "some question"
- questions = {
- "some_path": {
- "type": "path",
- "ask": ask_text,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill="",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
-def test_question_path_input_test_ask_with_default():
- ask_text = "some question"
- default_text = "someexample"
- questions = {
- "some_path": {
- "type": "path",
- "ask": ask_text,
- "default": default_text,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill=default_text,
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
@pytest.mark.skip # we should do something with this example
def test_question_path_input_test_ask_with_example():
ask_text = "some question"
@@ -897,898 +2116,6 @@ def test_question_path_input_test_ask_with_example():
assert example_text in prompt.call_args[1]["message"]
-@pytest.mark.skip # we should do something with this help
-def test_question_path_input_test_ask_with_help():
- ask_text = "some question"
- help_text = "some_help"
- questions = {
- "some_path": {
- "type": "path",
- "ask": ask_text,
- "help": help_text,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="some_value"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- assert ask_text in prompt.call_args[1]["message"]
- assert help_text in prompt.call_args[1]["message"]
-
-
-def test_question_boolean():
- questions = {
- "some_boolean": {
- "type": "boolean",
- }
- }
- answers = {"some_boolean": "y"}
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_boolean"
- assert out.type == "boolean"
- assert out.value == 1
-
-
-def test_question_boolean_all_yes():
- questions = {
- "some_boolean": {
- "type": "boolean",
- }
- }
-
- for value in ["Y", "yes", "Yes", "YES", "1", 1, True, "True", "TRUE", "true"]:
- out = ask_questions_and_parse_answers(questions, {"some_boolean": value})[0]
- assert out.name == "some_boolean"
- assert out.type == "boolean"
- assert out.value == 1
-
-
-def test_question_boolean_all_no():
- questions = {
- "some_boolean": {
- "type": "boolean",
- }
- }
-
- for value in ["n", "N", "no", "No", "No", "0", 0, False, "False", "FALSE", "false"]:
- out = ask_questions_and_parse_answers(questions, {"some_boolean": value})[0]
- assert out.name == "some_boolean"
- assert out.type == "boolean"
- assert out.value == 0
-
-
-# XXX apparently boolean are always False (0) by default, I'm not sure what to think about that
-def test_question_boolean_no_input():
- questions = {
- "some_boolean": {
- "type": "boolean",
- }
- }
- answers = {}
-
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.value == 0
-
-
-def test_question_boolean_bad_input():
- questions = {
- "some_boolean": {
- "type": "boolean",
- }
- }
- answers = {"some_boolean": "stuff"}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_boolean_input():
- questions = {
- "some_boolean": {
- "type": "boolean",
- "ask": "some question",
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="y"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.value == 1
-
- with patch.object(Moulinette, "prompt", return_value="n"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.value == 0
-
-
-def test_question_boolean_input_no_ask():
- questions = {
- "some_boolean": {
- "type": "boolean",
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="y"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.value == 1
-
-
-def test_question_boolean_no_input_optional():
- questions = {
- "some_boolean": {
- "type": "boolean",
- "optional": True,
- }
- }
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.value == 0
-
-
-def test_question_boolean_optional_with_input():
- questions = {
- "some_boolean": {
- "ask": "some question",
- "type": "boolean",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="y"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
- assert out.value == 1
-
-
-def test_question_boolean_optional_with_empty_input():
- questions = {
- "some_boolean": {
- "ask": "some question",
- "type": "boolean",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=""), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.value == 0
-
-
-def test_question_boolean_optional_with_input_without_ask():
- questions = {
- "some_boolean": {
- "type": "boolean",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="n"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.value == 0
-
-
-def test_question_boolean_no_input_default():
- questions = {
- "some_boolean": {
- "ask": "some question",
- "type": "boolean",
- "default": 0,
- }
- }
- answers = {}
-
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.value == 0
-
-
-def test_question_boolean_bad_default():
- questions = {
- "some_boolean": {
- "ask": "some question",
- "type": "boolean",
- "default": "bad default",
- }
- }
- answers = {}
- with pytest.raises(YunohostError):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_boolean_input_test_ask():
- ask_text = "some question"
- questions = {
- "some_boolean": {
- "type": "boolean",
- "ask": ask_text,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=0) as prompt, patch.object(
- os, "isatty", return_value=True
- ):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text + " [yes | no]",
- is_password=False,
- confirm=False,
- prefill="no",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
-def test_question_boolean_input_test_ask_with_default():
- ask_text = "some question"
- default_text = 1
- questions = {
- "some_boolean": {
- "type": "boolean",
- "ask": ask_text,
- "default": default_text,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value=1) as prompt, patch.object(
- os, "isatty", return_value=True
- ):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text + " [yes | no]",
- is_password=False,
- confirm=False,
- prefill="yes",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
-def test_question_domain_empty():
- questions = {
- "some_domain": {
- "type": "domain",
- }
- }
- main_domain = "my_main_domain.com"
- answers = {}
-
- with patch.object(
- domain, "_get_maindomain", return_value="my_main_domain.com"
- ), patch.object(
- domain, "domain_list", return_value={"domains": [main_domain]}
- ), patch.object(
- os, "isatty", return_value=False
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
-
-def test_question_domain():
- main_domain = "my_main_domain.com"
- domains = [main_domain]
- questions = {
- "some_domain": {
- "type": "domain",
- }
- }
-
- answers = {"some_domain": main_domain}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(domain, "domain_list", return_value={"domains": domains}):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
-
-def test_question_domain_two_domains():
- main_domain = "my_main_domain.com"
- other_domain = "some_other_domain.tld"
- domains = [main_domain, other_domain]
-
- questions = {
- "some_domain": {
- "type": "domain",
- }
- }
- answers = {"some_domain": other_domain}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(domain, "domain_list", return_value={"domains": domains}):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == other_domain
-
- answers = {"some_domain": main_domain}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(domain, "domain_list", return_value={"domains": domains}):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
-
-def test_question_domain_two_domains_wrong_answer():
- main_domain = "my_main_domain.com"
- other_domain = "some_other_domain.tld"
- domains = [main_domain, other_domain]
-
- questions = {
- "some_domain": {
- "type": "domain",
- }
- }
- answers = {"some_domain": "doesnt_exist.pouet"}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(domain, "domain_list", return_value={"domains": domains}):
- with pytest.raises(YunohostError), patch.object(
- os, "isatty", return_value=False
- ):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_domain_two_domains_default_no_ask():
- main_domain = "my_main_domain.com"
- other_domain = "some_other_domain.tld"
- domains = [main_domain, other_domain]
-
- questions = {
- "some_domain": {
- "type": "domain",
- }
- }
- answers = {}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(
- domain, "domain_list", return_value={"domains": domains}
- ), patch.object(
- os, "isatty", return_value=False
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
-
-def test_question_domain_two_domains_default():
- main_domain = "my_main_domain.com"
- other_domain = "some_other_domain.tld"
- domains = [main_domain, other_domain]
-
- questions = {"some_domain": {"type": "domain", "ask": "choose a domain"}}
- answers = {}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(
- domain, "domain_list", return_value={"domains": domains}
- ), patch.object(
- os, "isatty", return_value=False
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
-
-def test_question_domain_two_domains_default_input():
- main_domain = "my_main_domain.com"
- other_domain = "some_other_domain.tld"
- domains = [main_domain, other_domain]
-
- questions = {"some_domain": {"type": "domain", "ask": "choose a domain"}}
- answers = {}
-
- with patch.object(
- domain, "_get_maindomain", return_value=main_domain
- ), patch.object(
- domain, "domain_list", return_value={"domains": domains}
- ), patch.object(
- os, "isatty", return_value=True
- ):
- with patch.object(Moulinette, "prompt", return_value=main_domain):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == main_domain
-
- with patch.object(Moulinette, "prompt", return_value=other_domain):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_domain"
- assert out.type == "domain"
- assert out.value == other_domain
-
-
-def test_question_user_empty():
- users = {
- "some_user": {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- }
- }
-
- questions = {
- "some_user": {
- "type": "user",
- }
- }
- answers = {}
-
- with patch.object(user, "user_list", return_value={"users": users}):
- with pytest.raises(YunohostError), patch.object(
- os, "isatty", return_value=False
- ):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_user():
- username = "some_user"
- users = {
- username: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- }
- }
-
- questions = {
- "some_user": {
- "type": "user",
- }
- }
- answers = {"some_user": username}
-
- with patch.object(user, "user_list", return_value={"users": users}), patch.object(
- user, "user_info", return_value={}
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_user"
- assert out.type == "user"
- assert out.value == username
-
-
-def test_question_user_two_users():
- username = "some_user"
- other_user = "some_other_user"
- users = {
- username: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- },
- other_user: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "z@ynh.local",
- "fullname": "john doe",
- },
- }
-
- questions = {
- "some_user": {
- "type": "user",
- }
- }
- answers = {"some_user": other_user}
-
- with patch.object(user, "user_list", return_value={"users": users}), patch.object(
- user, "user_info", return_value={}
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_user"
- assert out.type == "user"
- assert out.value == other_user
-
- answers = {"some_user": username}
-
- with patch.object(user, "user_list", return_value={"users": users}), patch.object(
- user, "user_info", return_value={}
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_user"
- assert out.type == "user"
- assert out.value == username
-
-
-def test_question_user_two_users_wrong_answer():
- username = "my_username.com"
- other_user = "some_other_user"
- users = {
- username: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- },
- other_user: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "z@ynh.local",
- "fullname": "john doe",
- },
- }
-
- questions = {
- "some_user": {
- "type": "user",
- }
- }
- answers = {"some_user": "doesnt_exist.pouet"}
-
- with patch.object(user, "user_list", return_value={"users": users}):
- with pytest.raises(YunohostError), patch.object(
- os, "isatty", return_value=False
- ):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_user_two_users_no_default():
- username = "my_username.com"
- other_user = "some_other_user.tld"
- users = {
- username: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- },
- other_user: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "z@ynh.local",
- "fullname": "john doe",
- },
- }
-
- questions = {"some_user": {"type": "user", "ask": "choose a user"}}
- answers = {}
-
- with patch.object(user, "user_list", return_value={"users": users}):
- with pytest.raises(YunohostError), patch.object(
- os, "isatty", return_value=False
- ):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_user_two_users_default_input():
- username = "my_username.com"
- other_user = "some_other_user.tld"
- users = {
- username: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "p@ynh.local",
- "fullname": "the first name the last name",
- },
- other_user: {
- "ssh_allowed": False,
- "username": "some_user",
- "mailbox-quota": "0",
- "mail": "z@ynh.local",
- "fullname": "john doe",
- },
- }
-
- questions = {"some_user": {"type": "user", "ask": "choose a user"}}
- answers = {}
-
- with patch.object(user, "user_list", return_value={"users": users}), patch.object(
- os, "isatty", return_value=True
- ):
- with patch.object(user, "user_info", return_value={}):
-
- with patch.object(Moulinette, "prompt", return_value=username):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_user"
- assert out.type == "user"
- assert out.value == username
-
- with patch.object(Moulinette, "prompt", return_value=other_user):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_user"
- assert out.type == "user"
- assert out.value == other_user
-
-
-def test_question_number():
- questions = {
- "some_number": {
- "type": "number",
- }
- }
- answers = {"some_number": 1337}
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
-
-def test_question_number_no_input():
- questions = {
- "some_number": {
- "type": "number",
- }
- }
- answers = {}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_number_bad_input():
- questions = {
- "some_number": {
- "type": "number",
- }
- }
- answers = {"some_number": "stuff"}
-
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
- answers = {"some_number": 1.5}
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_number_input():
- questions = {
- "some_number": {
- "type": "number",
- "ask": "some question",
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="1337"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
- with patch.object(Moulinette, "prompt", return_value=1337), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
- with patch.object(Moulinette, "prompt", return_value="0"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 0
-
-
-def test_question_number_input_no_ask():
- questions = {
- "some_number": {
- "type": "number",
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="1337"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
-
-def test_question_number_no_input_optional():
- questions = {
- "some_number": {
- "type": "number",
- "optional": True,
- }
- }
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value is None
-
-
-def test_question_number_optional_with_input():
- questions = {
- "some_number": {
- "ask": "some question",
- "type": "number",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="1337"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
-
-def test_question_number_optional_with_input_without_ask():
- questions = {
- "some_number": {
- "type": "number",
- "optional": True,
- }
- }
- answers = {}
-
- with patch.object(Moulinette, "prompt", return_value="0"), patch.object(
- os, "isatty", return_value=True
- ):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 0
-
-
-def test_question_number_no_input_default():
- questions = {
- "some_number": {
- "ask": "some question",
- "type": "number",
- "default": 1337,
- }
- }
- answers = {}
- with patch.object(os, "isatty", return_value=False):
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_number"
- assert out.type == "number"
- assert out.value == 1337
-
-
-def test_question_number_bad_default():
- questions = {
- "some_number": {
- "ask": "some question",
- "type": "number",
- "default": "bad default",
- }
- }
- answers = {}
- with pytest.raises(YunohostError), patch.object(os, "isatty", return_value=False):
- ask_questions_and_parse_answers(questions, answers)
-
-
-def test_question_number_input_test_ask():
- ask_text = "some question"
- questions = {
- "some_number": {
- "type": "number",
- "ask": ask_text,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="1111"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill="",
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
-def test_question_number_input_test_ask_with_default():
- ask_text = "some question"
- default_value = 1337
- questions = {
- "some_number": {
- "type": "number",
- "ask": ask_text,
- "default": default_value,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="1111"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- prompt.assert_called_with(
- message=ask_text,
- is_password=False,
- confirm=False,
- prefill=str(default_value),
- is_multiline=False,
- autocomplete=[],
- help=None,
- )
-
-
@pytest.mark.skip # we should do something with this example
def test_question_number_input_test_ask_with_example():
ask_text = "some question"
@@ -1810,104 +2137,7 @@ def test_question_number_input_test_ask_with_example():
assert example_value in prompt.call_args[1]["message"]
-@pytest.mark.skip # we should do something with this help
-def test_question_number_input_test_ask_with_help():
- ask_text = "some question"
- help_value = 1337
- questions = {
- "some_number": {
- "type": "number",
- "ask": ask_text,
- "help": help_value,
- }
- }
- answers = {}
-
- with patch.object(
- Moulinette, "prompt", return_value="1111"
- ) as prompt, patch.object(os, "isatty", return_value=True):
- ask_questions_and_parse_answers(questions, answers)
- assert ask_text in prompt.call_args[1]["message"]
- assert help_value in prompt.call_args[1]["message"]
-
-
-def test_question_display_text():
- questions = {"some_app": {"type": "display_text", "ask": "foobar"}}
- answers = {}
-
- with patch.object(sys, "stdout", new_callable=StringIO) as stdout, patch.object(
- os, "isatty", return_value=True
- ):
- ask_questions_and_parse_answers(questions, answers)
- assert "foobar" in stdout.getvalue()
-
-
-def test_question_file_from_cli():
-
- FileQuestion.clean_upload_dirs()
-
- filename = "/tmp/ynh_test_question_file"
- os.system(f"rm -f {filename}")
- os.system(f"echo helloworld > {filename}")
-
- questions = {
- "some_file": {
- "type": "file",
- }
- }
- answers = {"some_file": filename}
-
- out = ask_questions_and_parse_answers(questions, answers)[0]
-
- assert out.name == "some_file"
- assert out.type == "file"
-
- # The file is supposed to be copied somewhere else
- assert out.value != filename
- assert out.value.startswith("/tmp/")
- assert os.path.exists(out.value)
- assert "helloworld" in open(out.value).read().strip()
-
- FileQuestion.clean_upload_dirs()
-
- assert not os.path.exists(out.value)
-
-
-def test_question_file_from_api():
-
- FileQuestion.clean_upload_dirs()
-
- from base64 import b64encode
-
- b64content = b64encode(b"helloworld")
- questions = {
- "some_file": {
- "type": "file",
- }
- }
- answers = {"some_file": b64content}
-
- interface_type_bkp = Moulinette.interface.type
- try:
- Moulinette.interface.type = "api"
- out = ask_questions_and_parse_answers(questions, answers)[0]
- finally:
- Moulinette.interface.type = interface_type_bkp
-
- assert out.name == "some_file"
- assert out.type == "file"
-
- assert out.value.startswith("/tmp/")
- assert os.path.exists(out.value)
- assert "helloworld" in open(out.value).read().strip()
-
- FileQuestion.clean_upload_dirs()
-
- assert not os.path.exists(out.value)
-
-
def test_normalize_boolean_nominal():
-
assert BooleanQuestion.normalize("yes") == 1
assert BooleanQuestion.normalize("Yes") == 1
assert BooleanQuestion.normalize(" yes ") == 1
@@ -1937,7 +2167,6 @@ def test_normalize_boolean_nominal():
def test_normalize_boolean_humanize():
-
assert BooleanQuestion.humanize("yes") == "yes"
assert BooleanQuestion.humanize("true") == "yes"
assert BooleanQuestion.humanize("on") == "yes"
@@ -1948,7 +2177,6 @@ def test_normalize_boolean_humanize():
def test_normalize_boolean_invalid():
-
with pytest.raises(YunohostValidationError):
BooleanQuestion.normalize("yesno")
with pytest.raises(YunohostValidationError):
@@ -1958,7 +2186,6 @@ def test_normalize_boolean_invalid():
def test_normalize_boolean_special_yesno():
-
customyesno = {"yes": "enabled", "no": "disabled"}
assert BooleanQuestion.normalize("yes", customyesno) == "enabled"
@@ -1977,14 +2204,12 @@ def test_normalize_boolean_special_yesno():
def test_normalize_domain():
-
assert DomainQuestion.normalize("https://yolo.swag/") == "yolo.swag"
assert DomainQuestion.normalize("http://yolo.swag") == "yolo.swag"
assert DomainQuestion.normalize("yolo.swag/") == "yolo.swag"
def test_normalize_path():
-
assert PathQuestion.normalize("") == "/"
assert PathQuestion.normalize("") == "/"
assert PathQuestion.normalize("macnuggets") == "/macnuggets"
diff --git a/src/tests/test_regenconf.py b/src/tests/test_regenconf.py
index f454f33e3..8dda1a7f2 100644
--- a/src/tests/test_regenconf.py
+++ b/src/tests/test_regenconf.py
@@ -16,19 +16,16 @@ SSHD_CONFIG = "/etc/ssh/sshd_config"
def setup_function(function):
-
_force_clear_hashes([TEST_DOMAIN_NGINX_CONFIG])
clean()
def teardown_function(function):
-
clean()
_force_clear_hashes([TEST_DOMAIN_NGINX_CONFIG])
def clean():
-
assert os.system("pgrep slapd >/dev/null") == 0
assert os.system("pgrep nginx >/dev/null") == 0
@@ -48,7 +45,6 @@ def clean():
def test_add_domain():
-
domain_add(TEST_DOMAIN)
assert TEST_DOMAIN in domain_list()["domains"]
@@ -60,7 +56,6 @@ def test_add_domain():
def test_add_and_edit_domain_conf():
-
domain_add(TEST_DOMAIN)
assert os.path.exists(TEST_DOMAIN_NGINX_CONFIG)
@@ -73,7 +68,6 @@ def test_add_and_edit_domain_conf():
def test_add_domain_conf_already_exists():
-
os.system("echo ' ' >> %s" % TEST_DOMAIN_NGINX_CONFIG)
domain_add(TEST_DOMAIN)
@@ -84,7 +78,6 @@ def test_add_domain_conf_already_exists():
def test_ssh_conf_unmanaged():
-
_force_clear_hashes([SSHD_CONFIG])
assert SSHD_CONFIG not in _get_conf_hashes("ssh")
@@ -95,7 +88,6 @@ def test_ssh_conf_unmanaged():
def test_ssh_conf_unmanaged_and_manually_modified(mocker):
-
_force_clear_hashes([SSHD_CONFIG])
os.system("echo ' ' >> %s" % SSHD_CONFIG)
diff --git a/src/tests/test_service.py b/src/tests/test_service.py
index 88013a3fe..84573fd89 100644
--- a/src/tests/test_service.py
+++ b/src/tests/test_service.py
@@ -14,17 +14,14 @@ from yunohost.service import (
def setup_function(function):
-
clean()
def teardown_function(function):
-
clean()
def clean():
-
# To run these tests, we assume ssh(d) service exists and is running
assert os.system("pgrep sshd >/dev/null") == 0
@@ -45,46 +42,39 @@ def clean():
def test_service_status_all():
-
status = service_status()
assert "ssh" in status.keys()
assert status["ssh"]["status"] == "running"
def test_service_status_single():
-
status = service_status("ssh")
assert "status" in status.keys()
assert status["status"] == "running"
def test_service_log():
-
logs = service_log("ssh")
assert "journalctl" in logs.keys()
assert "/var/log/auth.log" in logs.keys()
def test_service_status_unknown_service(mocker):
-
with raiseYunohostError(mocker, "service_unknown"):
service_status(["ssh", "doesnotexists"])
def test_service_add():
-
service_add("dummyservice", description="A dummy service to run tests")
assert "dummyservice" in service_status().keys()
def test_service_add_real_service():
-
service_add("networking")
assert "networking" in service_status().keys()
def test_service_remove():
-
service_add("dummyservice", description="A dummy service to run tests")
assert "dummyservice" in service_status().keys()
service_remove("dummyservice")
@@ -92,7 +82,6 @@ def test_service_remove():
def test_service_remove_service_that_doesnt_exists(mocker):
-
assert "dummyservice" not in service_status().keys()
with raiseYunohostError(mocker, "service_unknown"):
@@ -102,7 +91,6 @@ def test_service_remove_service_that_doesnt_exists(mocker):
def test_service_update_to_add_properties():
-
service_add("dummyservice", description="dummy")
assert not _get_services()["dummyservice"].get("test_status")
service_add("dummyservice", description="dummy", test_status="true")
@@ -110,7 +98,6 @@ def test_service_update_to_add_properties():
def test_service_update_to_change_properties():
-
service_add("dummyservice", description="dummy", test_status="false")
assert _get_services()["dummyservice"].get("test_status") == "false"
service_add("dummyservice", description="dummy", test_status="true")
@@ -118,7 +105,6 @@ def test_service_update_to_change_properties():
def test_service_update_to_remove_properties():
-
service_add("dummyservice", description="dummy", test_status="false")
assert _get_services()["dummyservice"].get("test_status") == "false"
service_add("dummyservice", description="dummy", test_status="")
@@ -126,7 +112,6 @@ def test_service_update_to_remove_properties():
def test_service_conf_broken():
-
os.system("echo pwet > /etc/nginx/conf.d/broken.conf")
status = service_status("nginx")
diff --git a/src/tests/test_settings.py b/src/tests/test_settings.py
index 2eaebba55..20f959a80 100644
--- a/src/tests/test_settings.py
+++ b/src/tests/test_settings.py
@@ -65,7 +65,6 @@ old_translate = moulinette.core.Translator.translate
def _monkeypatch_translator(self, key, *args, **kwargs):
-
if key.startswith("global_settings_setting_"):
return f"Dummy translation for {key}"
@@ -175,7 +174,6 @@ def test_settings_set_doesexit():
def test_settings_set_bad_type_bool():
-
with patch.object(os, "isatty", return_value=False):
with pytest.raises(YunohostError):
settings_set("example.example.boolean", 42)
diff --git a/src/tests/test_user-group.py b/src/tests/test_user-group.py
index 343431b69..eececb827 100644
--- a/src/tests/test_user-group.py
+++ b/src/tests/test_user-group.py
@@ -92,7 +92,6 @@ def test_list_groups():
def test_create_user(mocker):
-
with message(mocker, "user_created"):
user_create("albert", maindomain, "test123Ynh", fullname="Albert Good")
@@ -104,7 +103,6 @@ def test_create_user(mocker):
def test_del_user(mocker):
-
with message(mocker, "user_deleted"):
user_delete("alice")
@@ -185,7 +183,6 @@ def test_export_user(mocker):
def test_create_group(mocker):
-
with message(mocker, "group_created", group="adminsys"):
user_group_create("adminsys")
@@ -196,7 +193,6 @@ def test_create_group(mocker):
def test_del_group(mocker):
-
with message(mocker, "group_deleted", group="dev"):
user_group_delete("dev")
diff --git a/src/tools.py b/src/tools.py
index 79f10bc8c..33cccd729 100644
--- a/src/tools.py
+++ b/src/tools.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -62,7 +62,6 @@ def tools_versions():
def tools_rootpw(new_password, check_strength=True):
-
from yunohost.user import _hash_user_password
from yunohost.utils.password import (
assert_password_is_strong_enough,
@@ -154,8 +153,8 @@ def tools_postinstall(
dyndns_recovery_password=None,
ignore_dyndns=False,
force_diskspace=False,
+ overwrite_root_password=True,
):
-
from yunohost.dyndns import _dyndns_available
from yunohost.utils.dns import is_yunohost_dyndns_domain
from yunohost.utils.password import (
@@ -163,7 +162,7 @@ def tools_postinstall(
assert_password_is_compatible,
)
from yunohost.domain import domain_main_domain
- from yunohost.user import user_create
+ from yunohost.user import user_create, ADMIN_ALIASES
import psutil
# Do some checks at first
@@ -176,6 +175,11 @@ def tools_postinstall(
raw_msg=True,
)
+ if username in ADMIN_ALIASES:
+ raise YunohostValidationError(
+ f"Unfortunately, {username} cannot be used as a username", raw_msg=True
+ )
+
# Check there's at least 10 GB on the rootfs...
disk_partitions = sorted(
psutil.disk_partitions(all=True), key=lambda k: k.mountpoint
@@ -193,7 +197,7 @@ def tools_postinstall(
assert_password_is_strong_enough("admin", password)
# If this is a nohost.me/noho.st, actually check for availability
- if is_yunohost_dyndns_domain(domain):
+ if not ignore_dyndns and is_yunohost_dyndns_domain(domain):
if (bool(dyndns_recovery_password), ignore_dyndns) in [(True, True), (False, False)]:
raise YunohostValidationError("domain_dyndns_instruction_unclear")
@@ -223,10 +227,11 @@ def tools_postinstall(
domain_add(domain, dyndns_recovery_password=dyndns_recovery_password, ignore_dyndns=ignore_dyndns)
domain_main_domain(domain)
+ # First user
user_create(username, domain, password, admin=True, fullname=fullname)
- # Update LDAP admin and create home dir
- tools_rootpw(password)
+ if overwrite_root_password:
+ tools_rootpw(password)
# Enable UPnP silently and reload firewall
firewall_upnp("enable", no_refresh=True)
@@ -276,7 +281,6 @@ def tools_postinstall(
def tools_regen_conf(
names=[], with_diff=False, force=False, dry_run=False, list_pending=False
):
-
# Make sure the settings are migrated before running the migration,
# which may otherwise fuck things up such as the ssh config ...
# We do this here because the regen-conf is called before the migration in debian/postinst
@@ -307,7 +311,6 @@ def tools_update(target=None):
upgradable_system_packages = []
if target in ["system", "all"]:
-
# Update APT cache
# LC_ALL=C is here to make sure the results are in english
command = (
@@ -411,7 +414,8 @@ def tools_upgrade(operation_logger, target=None):
if target not in ["apps", "system"]:
raise YunohostValidationError(
- "Uhoh ?! tools_upgrade should have 'apps' or 'system' value for argument target"
+ "Uhoh ?! tools_upgrade should have 'apps' or 'system' value for argument target",
+ raw_msg=True,
)
#
@@ -420,7 +424,6 @@ def tools_upgrade(operation_logger, target=None):
#
if target == "apps":
-
# Make sure there's actually something to upgrade
upgradable_apps = [app["id"] for app in app_list(upgradable=True)["apps"]]
@@ -444,7 +447,6 @@ def tools_upgrade(operation_logger, target=None):
#
if target == "system":
-
# Check that there's indeed some packages to upgrade
upgradables = list(_list_upgradable_apt_packages())
if not upgradables:
@@ -470,13 +472,6 @@ def tools_upgrade(operation_logger, target=None):
logger.debug("Running apt command :\n{}".format(dist_upgrade))
- def is_relevant(line):
- irrelevants = [
- "service sudo-ldap already provided",
- "Reading database ...",
- ]
- return all(i not in line.rstrip() for i in irrelevants)
-
callbacks = (
lambda l: logger.info("+ " + l.rstrip() + "\r")
if _apt_log_line_is_relevant(l)
@@ -492,7 +487,6 @@ def tools_upgrade(operation_logger, target=None):
any(p["name"] == "yunohost" for p in upgradables)
and Moulinette.interface.type == "api"
):
-
# Restart the API after 10 sec (at now doesn't support sub-minute times...)
# We do this so that the API / webadmin still gets the proper HTTP response
# It's then up to the webadmin to implement a proper UX process to wait 10 sec and then auto-fresh the webadmin
@@ -505,7 +499,7 @@ def tools_upgrade(operation_logger, target=None):
logger.warning(
m18n.n(
"tools_upgrade_failed",
- packages_list=", ".join(upgradables),
+ packages_list=", ".join([p["name"] for p in upgradables]),
)
)
@@ -716,7 +710,6 @@ def tools_migrations_run(
# Actually run selected migrations
for migration in targets:
-
# If we are migrating in "automatic mode" (i.e. from debian configure
# during an upgrade of the package) but we are asked for running
# migrations to be ran manually by the user, stop there and ask the
@@ -772,7 +765,6 @@ def tools_migrations_run(
_write_migration_state(migration.id, "skipped")
operation_logger.success()
else:
-
try:
migration.operation_logger = operation_logger
logger.info(m18n.n("migrations_running_forward", id=migration.id))
@@ -804,14 +796,12 @@ def tools_migrations_state():
def _write_migration_state(migration_id, state):
-
current_states = tools_migrations_state()
current_states["migrations"][migration_id] = state
write_to_yaml(MIGRATIONS_STATE_PATH, current_states)
def _get_migrations_list():
-
# states is a datastructure that represents the last run migration
# it has this form:
# {
@@ -862,7 +852,6 @@ def _get_migration_by_name(migration_name):
def _load_migration(migration_file):
-
migration_id = migration_file[: -len(".py")]
logger.debug(m18n.n("migrations_loading_migration", id=migration_id))
@@ -897,7 +886,6 @@ def _skip_all_migrations():
def _tools_migrations_run_after_system_restore(backup_version):
-
all_migrations = _get_migrations_list()
current_version = version.parse(ynh_packages_version()["yunohost"]["version"])
@@ -924,7 +912,6 @@ def _tools_migrations_run_after_system_restore(backup_version):
def _tools_migrations_run_before_app_restore(backup_version, app_id):
-
all_migrations = _get_migrations_list()
current_version = version.parse(ynh_packages_version()["yunohost"]["version"])
@@ -951,7 +938,6 @@ def _tools_migrations_run_before_app_restore(backup_version, app_id):
class Migration:
-
# Those are to be implemented by daughter classes
mode = "auto"
@@ -979,7 +965,6 @@ class Migration:
def ldap_migration(run):
def func(self):
-
# Backup LDAP before the migration
logger.info(m18n.n("migration_ldap_backup_before_migration"))
try:
diff --git a/src/user.py b/src/user.py
index deaebba5b..f17a60942 100644
--- a/src/user.py
+++ b/src/user.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -53,7 +53,6 @@ ADMIN_ALIASES = ["root", "admin", "admins", "webmaster", "postmaster", "abuse"]
def user_list(fields=None):
-
from yunohost.utils.ldap import _get_ldap_interface
ldap_attrs = {
@@ -123,6 +122,18 @@ def user_list(fields=None):
return {"users": users}
+def list_shells():
+ with open("/etc/shells", "r") as f:
+ content = f.readlines()
+
+ return [line.strip() for line in content if line.startswith("/")]
+
+
+def shellexists(shell):
+ """Check if the provided shell exists and is executable."""
+ return os.path.isfile(shell) and os.access(shell, os.X_OK)
+
+
@is_unit_operation([("username", "user")])
def user_create(
operation_logger,
@@ -135,8 +146,8 @@ def user_create(
mailbox_quota="0",
admin=False,
from_import=False,
+ loginShell=None,
):
-
if firstname or lastname:
logger.warning(
"Options --firstname / --lastname of 'yunohost user create' are deprecated. We recommend using --fullname instead."
@@ -230,6 +241,12 @@ def user_create(
uid = str(random.randint(1001, 65000))
uid_guid_found = uid not in all_uid and uid not in all_gid
+ if not loginShell:
+ loginShell = "/bin/bash"
+ else:
+ if not shellexists(loginShell) or loginShell not in list_shells():
+ raise YunohostValidationError("invalid_shell", shell=loginShell)
+
attr_dict = {
"objectClass": [
"mailAccount",
@@ -249,7 +266,7 @@ def user_create(
"gidNumber": [uid],
"uidNumber": [uid],
"homeDirectory": ["/home/" + username],
- "loginShell": ["/bin/bash"],
+ "loginShell": [loginShell],
}
try:
@@ -300,7 +317,6 @@ def user_create(
@is_unit_operation([("username", "user")])
def user_delete(operation_logger, username, purge=False, from_import=False):
-
from yunohost.hook import hook_callback
from yunohost.utils.ldap import _get_ldap_interface
@@ -359,8 +375,8 @@ def user_update(
mailbox_quota=None,
from_import=False,
fullname=None,
+ loginShell=None,
):
-
if firstname or lastname:
logger.warning(
"Options --firstname / --lastname of 'yunohost user create' are deprecated. We recommend using --fullname instead."
@@ -519,6 +535,12 @@ def user_update(
new_attr_dict["mailuserquota"] = [mailbox_quota]
env_dict["YNH_USER_MAILQUOTA"] = mailbox_quota
+ if loginShell is not None:
+ if not shellexists(loginShell) or loginShell not in list_shells():
+ raise YunohostValidationError("invalid_shell", shell=loginShell)
+ new_attr_dict["loginShell"] = [loginShell]
+ env_dict["YNH_USER_LOGINSHELL"] = loginShell
+
if not from_import:
operation_logger.start()
@@ -527,6 +549,10 @@ def user_update(
except Exception as e:
raise YunohostError("user_update_failed", user=username, error=e)
+ # Invalidate passwd and group to update the loginShell
+ subprocess.call(["nscd", "-i", "passwd"])
+ subprocess.call(["nscd", "-i", "group"])
+
# Trigger post_user_update hooks
hook_callback("post_user_update", env=env_dict)
@@ -548,7 +574,7 @@ def user_info(username):
ldap = _get_ldap_interface()
- user_attrs = ["cn", "mail", "uid", "maildrop", "mailuserquota"]
+ user_attrs = ["cn", "mail", "uid", "maildrop", "mailuserquota", "loginShell"]
if len(username.split("@")) == 2:
filter = "mail=" + username
@@ -566,6 +592,7 @@ def user_info(username):
"username": user["uid"][0],
"fullname": user["cn"][0],
"mail": user["mail"][0],
+ "loginShell": user["loginShell"][0],
"mail-aliases": [],
"mail-forward": [],
}
@@ -604,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:
@@ -704,7 +731,6 @@ def user_import(operation_logger, csvfile, update=False, delete=False):
)
for user in reader:
-
# Validate column values against regexes
format_errors = [
f"{key}: '{user[key]}' doesn't match the expected format"
@@ -960,7 +986,6 @@ def user_group_list(short=False, full=False, include_primary_groups=True):
users = user_list()["users"]
groups = {}
for infos in groups_infos:
-
name = infos["cn"][0]
if not include_primary_groups and name in users:
@@ -1110,7 +1135,6 @@ def user_group_update(
sync_perm=True,
from_import=False,
):
-
from yunohost.permission import permission_sync_to_user
from yunohost.utils.ldap import _get_ldap_interface, _ldap_path_extract
@@ -1153,7 +1177,6 @@ def user_group_update(
new_attr_dict = {}
if add:
-
users_to_add = [add] if not isinstance(add, list) else add
for user in users_to_add:
@@ -1194,7 +1217,6 @@ def user_group_update(
# Check the whole alias situation
if add_mailalias:
-
from yunohost.domain import domain_list
domains = domain_list()["domains"]
@@ -1238,7 +1260,6 @@ def user_group_update(
raise YunohostValidationError("mail_alias_remove_failed", mail=mail)
if set(new_group_mail) != set(current_group_mail):
-
logger.info(m18n.n("group_update_aliases", group=groupname))
new_attr_dict["mail"] = set(new_group_mail)
@@ -1446,7 +1467,6 @@ def _hash_user_password(password):
def _update_admins_group_aliases(old_main_domain, new_main_domain):
-
current_admin_aliases = user_group_info("admins")["mail-aliases"]
aliases_to_remove = [
diff --git a/src/utils/__init__.py b/src/utils/__init__.py
index 5cad500fa..7c1e7b0cd 100644
--- a/src/utils/__init__.py
+++ b/src/utils/__init__.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
diff --git a/src/utils/configpanel.py b/src/utils/configpanel.py
new file mode 100644
index 000000000..e50d0a3ec
--- /dev/null
+++ b/src/utils/configpanel.py
@@ -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 .
+#
+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)") + "$", 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)
diff --git a/src/utils/dns.py b/src/utils/dns.py
index 091168615..b3ca4b564 100644
--- a/src/utils/dns.py
+++ b/src/utils/dns.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -31,19 +31,16 @@ external_resolvers_: List[str] = []
def is_yunohost_dyndns_domain(domain):
-
return any(
domain.endswith(f".{dyndns_domain}") for dyndns_domain in YNH_DYNDNS_DOMAINS
)
def is_special_use_tld(domain):
-
return any(domain.endswith(f".{tld}") for tld in SPECIAL_USE_TLDS)
def external_resolvers():
-
global external_resolvers_
if not external_resolvers_:
diff --git a/src/utils/error.py b/src/utils/error.py
index e7046540d..9be48c5df 100644
--- a/src/utils/error.py
+++ b/src/utils/error.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -21,7 +21,6 @@ from moulinette import m18n
class YunohostError(MoulinetteError):
-
http_code = 500
"""
@@ -43,7 +42,6 @@ class YunohostError(MoulinetteError):
super(YunohostError, self).__init__(msg, raw_msg=True)
def content(self):
-
if not self.log_ref:
return super().content()
else:
@@ -51,14 +49,11 @@ class YunohostError(MoulinetteError):
class YunohostValidationError(YunohostError):
-
http_code = 400
def content(self):
-
return {"error": self.strerror, "error_key": self.key, **self.kwargs}
class YunohostAuthenticationError(MoulinetteAuthenticationError):
-
pass
diff --git a/src/utils/config.py b/src/utils/form.py
similarity index 55%
rename from src/utils/config.py
rename to src/utils/form.py
index 27e4b9509..31b3d5b87 100644
--- a/src/utils/config.py
+++ b/src/utils/form.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -16,7 +16,6 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see .
#
-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,657 +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)") + "$", 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:
- raise YunohostError(
- "config_version_not_supported", version=toml_config_panel["version"]
- )
-
- # 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["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
@@ -862,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)
@@ -904,7 +248,6 @@ class Question:
)
def ask_if_needed(self):
-
if self.visible and not evaluate_simple_js_expression(
self.visible, context=self.context
):
@@ -969,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(
@@ -979,7 +322,6 @@ class Question:
)
def _format_text_for_user_input_in_cli(self):
-
text_for_user_input_in_cli = _value_for_locale(self.ask)
if self.readonly:
@@ -990,7 +332,6 @@ class Question:
)
return text_for_user_input_in_cli + f" {self.humanize(self.current_value)}"
elif self.choices:
-
# Prevent displaying a shitload of choices
# (e.g. 100+ available users when choosing an app admin...)
choices = (
@@ -1094,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
@@ -1111,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()
@@ -1159,9 +515,15 @@ class PathQuestion(Question):
@staticmethod
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 ""
@@ -1186,7 +548,6 @@ class BooleanQuestion(Question):
@staticmethod
def humanize(value, option={}):
-
option = option.__dict__ if isinstance(option, Question) else option
yes = option.get("yes", 1)
@@ -1210,7 +571,6 @@ class BooleanQuestion(Question):
@staticmethod
def normalize(value, option={}):
-
option = option.__dict__ if isinstance(option, Question) else option
if isinstance(value, str):
@@ -1367,12 +727,13 @@ class GroupQuestion(Question):
def __init__(
self, question, context: Mapping[str, Any] = {}, hooks: Dict[str, Callable] = {}
):
-
from yunohost.user import user_group_list
super().__init__(question, context)
- self.choices = list(user_group_list(short=True)["groups"])
+ self.choices = list(
+ user_group_list(short=True, include_primary_groups=False)["groups"]
+ )
def _human_readable_group(g):
# i18n: visitors
@@ -1400,7 +761,6 @@ class NumberQuestion(Question):
@staticmethod
def normalize(value, option={}):
-
if isinstance(value, int):
return value
@@ -1411,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(
@@ -1493,8 +853,14 @@ 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,
@@ -1505,7 +871,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)
diff --git a/src/utils/i18n.py b/src/utils/i18n.py
index ecbfe36e8..2aafafbdd 100644
--- a/src/utils/i18n.py
+++ b/src/utils/i18n.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
diff --git a/src/utils/ldap.py b/src/utils/ldap.py
index ee50d0b98..6b41cdb22 100644
--- a/src/utils/ldap.py
+++ b/src/utils/ldap.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -36,7 +36,6 @@ _ldap_interface = None
def _get_ldap_interface():
-
global _ldap_interface
if _ldap_interface is None:
diff --git a/src/utils/legacy.py b/src/utils/legacy.py
index 3334632c2..82507d64d 100644
--- a/src/utils/legacy.py
+++ b/src/utils/legacy.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -193,7 +193,6 @@ LEGACY_PHP_VERSION_REPLACEMENTS = [
def _patch_legacy_php_versions(app_folder):
-
files_to_patch = []
files_to_patch.extend(glob.glob("%s/conf/*" % app_folder))
files_to_patch.extend(glob.glob("%s/scripts/*" % app_folder))
@@ -203,7 +202,6 @@ def _patch_legacy_php_versions(app_folder):
files_to_patch.append("%s/manifest.toml" % app_folder)
for filename in files_to_patch:
-
# Ignore non-regular files
if not os.path.isfile(filename):
continue
@@ -217,7 +215,6 @@ def _patch_legacy_php_versions(app_folder):
def _patch_legacy_php_versions_in_settings(app_folder):
-
settings = read_yaml(os.path.join(app_folder, "settings.yml"))
if settings.get("fpm_config_dir") in ["/etc/php/7.0/fpm", "/etc/php/7.3/fpm"]:
@@ -243,7 +240,6 @@ def _patch_legacy_php_versions_in_settings(app_folder):
def _patch_legacy_helpers(app_folder):
-
files_to_patch = []
files_to_patch.extend(glob.glob("%s/scripts/*" % app_folder))
files_to_patch.extend(glob.glob("%s/scripts/.*" % app_folder))
@@ -291,7 +287,6 @@ def _patch_legacy_helpers(app_folder):
infos["replace"] = infos.get("replace")
for filename in files_to_patch:
-
# Ignore non-regular files
if not os.path.isfile(filename):
continue
@@ -305,7 +300,6 @@ def _patch_legacy_helpers(app_folder):
show_warning = False
for helper, infos in stuff_to_replace.items():
-
# Ignore if not relevant for this file
if infos.get("only_for") and not any(
filename.endswith(f) for f in infos["only_for"]
@@ -329,7 +323,6 @@ def _patch_legacy_helpers(app_folder):
)
if replaced_stuff:
-
# Check the app do load the helper
# If it doesn't, add the instruction ourselve (making sure it's after the #!/bin/bash if it's there...
if filename.split("/")[-1] in [
diff --git a/src/utils/network.py b/src/utils/network.py
index 06dd3493d..2a13f966e 100644
--- a/src/utils/network.py
+++ b/src/utils/network.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -29,7 +29,6 @@ logger = logging.getLogger("yunohost.utils.network")
def get_public_ip(protocol=4):
-
assert protocol in [4, 6], (
"Invalid protocol version for get_public_ip: %s, expected 4 or 6" % protocol
)
@@ -90,7 +89,6 @@ def get_public_ip_from_remote_server(protocol=4):
def get_network_interfaces():
-
# Get network devices and their addresses (raw infos from 'ip addr')
devices_raw = {}
output = check_output("ip addr show")
@@ -111,7 +109,6 @@ def get_network_interfaces():
def get_gateway():
-
output = check_output("ip route show")
m = re.search(r"default via (.*) dev ([a-z]+[0-9]?)", output)
if not m:
diff --git a/src/utils/password.py b/src/utils/password.py
index 3202e8055..833933d33 100644
--- a/src/utils/password.py
+++ b/src/utils/password.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -58,7 +58,6 @@ def assert_password_is_compatible(password):
"""
if len(password) >= 127:
-
# Note that those imports are made here and can't be put
# on top (at least not the moulinette ones)
# because the moulinette needs to be correctly initialized
@@ -69,7 +68,6 @@ def assert_password_is_compatible(password):
def assert_password_is_strong_enough(profile, password):
-
PasswordValidator(profile).validate(password)
@@ -197,7 +195,6 @@ class PasswordValidator:
return strength_level
def is_in_most_used_list(self, password):
-
# Decompress file if compressed
if os.path.exists("%s.gz" % MOST_USED_PASSWORDS):
os.system("gzip -fd %s.gz" % MOST_USED_PASSWORDS)
diff --git a/src/utils/resources.py b/src/utils/resources.py
index 7b500ad3f..1fbfdbd04 100644
--- a/src/utils/resources.py
+++ b/src/utils/resources.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -20,6 +20,8 @@ import os
import copy
import shutil
import random
+import tempfile
+import subprocess
from typing import Dict, Any, List
from moulinette import m18n
@@ -29,7 +31,7 @@ from moulinette.utils.filesystem import mkdir, chown, chmod, write_to_file
from moulinette.utils.filesystem import (
rm,
)
-
+from yunohost.utils.system import system_arch
from yunohost.utils.error import YunohostError, YunohostValidationError
logger = getActionLogger("yunohost.app_resources")
@@ -37,7 +39,6 @@ logger = getActionLogger("yunohost.app_resources")
class AppResourceManager:
def __init__(self, app: str, current: Dict, wanted: Dict):
-
self.app = app
self.current = current
self.wanted = wanted
@@ -50,7 +51,6 @@ class AppResourceManager:
def apply(
self, rollback_and_raise_exception_if_failure, operation_logger=None, **context
):
-
todos = list(self.compute_todos())
completed = []
rollback = False
@@ -60,13 +60,13 @@ class AppResourceManager:
try:
if todo == "deprovision":
# FIXME : i18n, better info strings
- logger.info(f"Deprovisionning {name} ...")
+ logger.info(f"Deprovisionning {name}...")
old.deprovision(context=context)
elif todo == "provision":
- logger.info(f"Provisionning {name} ...")
+ logger.info(f"Provisionning {name}...")
new.provision_or_update(context=context)
elif todo == "update":
- logger.info(f"Updating {name} ...")
+ logger.info(f"Updating {name}...")
new.provision_or_update(context=context)
except (KeyboardInterrupt, Exception) as e:
exception = e
@@ -89,13 +89,13 @@ class AppResourceManager:
# (NB. here we want to undo the todo)
if todo == "deprovision":
# FIXME : i18n, better info strings
- logger.info(f"Reprovisionning {name} ...")
+ logger.info(f"Reprovisionning {name}...")
old.provision_or_update(context=context)
elif todo == "provision":
- logger.info(f"Deprovisionning {name} ...")
+ logger.info(f"Deprovisionning {name}...")
new.deprovision(context=context)
elif todo == "update":
- logger.info(f"Reverting {name} ...")
+ logger.info(f"Reverting {name}...")
old.provision_or_update(context=context)
except (KeyboardInterrupt, Exception) as e:
if isinstance(e, KeyboardInterrupt):
@@ -121,7 +121,6 @@ class AppResourceManager:
logger.error(exception)
def compute_todos(self):
-
for name, infos in reversed(self.current["resources"].items()):
if name not in self.wanted["resources"].keys():
resource = AppResourceClassesByType[name](infos, self.app, self)
@@ -140,12 +139,10 @@ class AppResourceManager:
class AppResource:
-
type: str = ""
default_properties: Dict[str, Any] = {}
def __init__(self, properties: Dict[str, Any], app: str, manager=None):
-
self.app = app
self.manager = manager
@@ -174,8 +171,29 @@ class AppResource:
app_setting(self.app, key, delete=True)
- def _run_script(self, action, script, env={}, user="root"):
+ def check_output_bash_snippet(self, snippet, env={}):
+ from yunohost.app import (
+ _make_environment_for_app_script,
+ )
+ env_ = _make_environment_for_app_script(
+ self.app,
+ force_include_app_settings=True,
+ )
+ env_.update(env)
+
+ with tempfile.NamedTemporaryFile(prefix="ynh_") as fp:
+ fp.write(snippet.encode())
+ fp.seek(0)
+ with tempfile.TemporaryFile() as stderr:
+ out = check_output(f"bash {fp.name}", env=env_, stderr=stderr)
+
+ stderr.seek(0)
+ err = stderr.read().decode()
+
+ return out, err
+
+ def _run_script(self, action, script, env={}):
from yunohost.app import (
_make_tmp_workdir_for_app,
_make_environment_for_app_script,
@@ -185,7 +203,10 @@ class AppResource:
tmpdir = _make_tmp_workdir_for_app(app=self.app)
env_ = _make_environment_for_app_script(
- self.app, workdir=tmpdir, action=f"{action}_{self.type}"
+ self.app,
+ workdir=tmpdir,
+ action=f"{action}_{self.type}",
+ force_include_app_settings=True,
)
env_.update(env)
@@ -201,9 +222,12 @@ ynh_abort_if_errors
from yunohost.log import OperationLogger
- if OperationLogger._instances:
- # FIXME ? : this is an ugly hack :(
- operation_logger = OperationLogger._instances[-1]
+ # FIXME ? : this is an ugly hack :(
+ active_operation_loggers = [
+ o for o in OperationLogger._instances if o.ended_at is None
+ ]
+ if active_operation_loggers:
+ operation_logger = active_operation_loggers[-1]
else:
operation_logger = OperationLogger(
"resource_snippet", [("app", self.app)], env=env_
@@ -228,13 +252,208 @@ ynh_abort_if_errors
)
else:
# FIXME: currently in app install code, we have
- # more sophisticated code checking if this broke something on the system etc ...
+ # more sophisticated code checking if this broke something on the system etc.
# dunno if we want to do this here or manage it elsewhere
pass
# 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.
+
+ This resource is intended both to declare the assets, which will be parsed by ynh_setup_source during the app script runtime, AND to prefetch and validate the sha256sum of those asset before actually running the script, to be able to report an error early when the asset turns out to not be available for some reason.
+
+ Various options are available to accomodate the behavior according to the asset structure
+
+ ##### Example
+
+ ```toml
+ [resources.sources]
+
+ [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
+
+ ```toml
+ [resources.sources]
+
+ [resources.sources.main]
+ 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.386.tar.gz"
+ i386.sha256 = "53c234e5e8472b6ac51c1ae1cab3fe06fad053beb8ebfd8977b010655bfdd3c3"
+ 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"
+ format = "script"
+ rename = "zblerg.sh"
+
+ ```
+
+ ##### 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
+ - If the asset's URL depend on the architecture, you may instead provide `amd64.url`, `i386.url`, `armhf.url` and `arm64.url` (depending on what architectures are supported), using the same `dpkg --print-architecture` nomenclature as for the supported architecture key in the manifest
+ - `sha256` : the asset's sha256sum. This is used both as an integrity check, and as a layer of security to protect against malicious actors which could have injected malicious code inside the asset...
+ - Same as `url` : if the asset's URL depend on the architecture, you may instead provide `amd64.sha256`, `i386.sha256`, ...
+ - `format` : The "format" of the asset. It is typically automatically guessed from the extension of the URL (or the mention of "tarball", "zipball" in the URL), but can be set explicitly:
+ - `tar.gz`, `tar.xz`, `tar.bz2` : will use `tar` to extract the archive
+ - `zip` : will use `unzip` to extract the archive
+ - `docker` : useful to extract files from an already-built docker image (instead of rebuilding them locally). Will use `docker-image-extract`
+ - `whatever`: whatever arbitrary value, not really meaningful except to imply that the file won't be extracted (eg because it's a .deb to be manually installed with dpkg/apt, or a script, or ...)
+ - `in_subdir`: `true` (default) or `false`, depending on if there's an intermediate subdir in the archive before accessing the actual files. Can also be `N` (an integer) to handle special cases where there's `N` level of subdir to get rid of to actually access the files
+ - `extract` : `true` or `false`. Defaults to `true` for archives such as `zip`, `tar.gz`, `tar.bz2`, ... Or defaults to `false` when `format` is not something that should be extracted. When `extract = false`, the file will only be `mv`ed to the location, possibly renamed using the `rename` value
+ - `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`
+
+ 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
+ - Nothing (just cleanup the cache)
+ """
+
+ type = "sources"
+ priority = 10
+
+ default_sources_properties: Dict[str, Any] = {
+ "prefetch": True,
+ "url": None,
+ "sha256": None,
+ }
+
+ 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)
+
+ super().__init__({"sources": properties}, *args, **kwargs)
+
+ def deprovision(self, context: Dict = {}):
+ if os.path.isdir(f"/var/cache/yunohost/download/{self.app}/"):
+ 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)
+ ):
+ 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,
+ )
+ 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,
+ )
+ 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}/"):
+ mkdir(f"/var/cache/yunohost/download/{self.app}/", parents=True)
+ filename = f"/var/cache/yunohost/download/{self.app}/{source_id}"
+
+ # 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,
+ )
+ 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(),
+ )
+
+ 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,
+ )
+
+
class PermissionsResource(AppResource):
"""
Configure the SSO permissions/tiles. Typically, webapps are expected to have a 'main' permission mapped to '/', meaning that a tile pointing to the `$domain/$path` will be available in the SSO for users allowed to access that app.
@@ -243,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 = "/"
@@ -254,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.
@@ -262,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
"""
@@ -295,27 +514,66 @@ class PermissionsResource(AppResource):
permissions: Dict[str, Dict[str, Any]] = {}
def __init__(self, properties: Dict[str, Any], *args, **kwargs):
-
# FIXME : if url != None, we should check that there's indeed a domain/path defined ? ie that app is a webapp
+ # Validate packager-provided infos
+ 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,
+ )
+
+ if "main" not in properties:
+ properties["main"] = copy.copy(self.default_perm_properties)
+
for perm, infos in properties.items():
properties[perm] = copy.copy(self.default_perm_properties)
properties[perm].update(infos)
if properties[perm]["show_tile"] is None:
properties[perm]["show_tile"] = bool(properties[perm]["url"])
- if (
- isinstance(properties["main"]["url"], str)
- and properties["main"]["url"] != "/"
+ if properties["main"]["url"] is not None and (
+ not isinstance(properties["main"].get("url"), str)
+ or properties["main"]["url"] != "/"
):
raise YunohostError(
- "URL for the 'main' permission should be '/' for webapps (or undefined/None for non-webapps). Note that / refers to the install url of the app"
+ "URL for the 'main' permission should be '/' for webapps (or left undefined for non-webapps). Note that / refers to the install url of the app, i.e $domain.tld/$path/",
+ raw_msg=True,
)
super().__init__({"permissions": properties}, *args, **kwargs)
- def provision_or_update(self, context: Dict = {}):
+ from yunohost.app import _get_app_settings, _hydrate_app_template
+ settings = _get_app_settings(self.app)
+ for perm, infos in self.permissions.items():
+ if infos.get("url") and "__" in infos.get("url"):
+ infos["url"] = _hydrate_app_template(infos["url"], settings)
+
+ if infos.get("additional_urls"):
+ infos["additional_urls"] = [_hydrate_app_template(url) for url in infos["additional_urls"]]
+
+ def provision_or_update(self, context: Dict = {}):
from yunohost.permission import (
permission_create,
permission_url,
@@ -328,6 +586,16 @@ class PermissionsResource(AppResource):
# Delete legacy is_public setting if not already done
self.delete_setting("is_public")
+ # Detect that we're using a full-domain app,
+ # in which case we probably need to automagically
+ # define the "path" setting with "/"
+ if (
+ isinstance(self.permissions["main"]["url"], str)
+ and self.get_setting("domain")
+ and not self.get_setting("path")
+ ):
+ self.set_setting("path", "/")
+
existing_perms = user_permission_list(short=True, apps=[self.app])[
"permissions"
]
@@ -346,6 +614,11 @@ class PermissionsResource(AppResource):
or self.get_setting(f"init_{perm}_permission")
or []
)
+
+ # If we're choosing 'visitors' from the init_{perm}_permission question, add all_users too
+ if not infos["allowed"] and init_allowed == "visitors":
+ init_allowed = ["visitors", "all_users"]
+
permission_create(
perm_id,
allowed=init_allowed,
@@ -375,7 +648,6 @@ class PermissionsResource(AppResource):
permission_sync_to_user()
def deprovision(self, context: Dict = {}):
-
from yunohost.permission import (
permission_delete,
user_permission_list,
@@ -395,21 +667,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`: (defalt: False) Adds the user to the sftp.app group, allowing SFTP 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
"""
@@ -423,30 +696,34 @@ class SystemuserAppResource(AppResource):
type = "system_user"
priority = 20
- default_properties: Dict[str, Any] = {"allow_ssh": False, "allow_sftp": False}
+ default_properties: Dict[str, Any] = {
+ "allow_ssh": False,
+ "allow_sftp": False,
+ "home": "/var/www/__APP__",
+ }
- # FIXME : wat do regarding ssl-cert, multimedia
- # FIXME : wat do about home dir
+ # FIXME : wat do regarding ssl-cert, multimedia, and other groups
allow_ssh: bool = False
allow_sftp: bool = False
+ home: str = ""
def provision_or_update(self, context: Dict = {}):
-
# FIXME : validate that no yunohost user exists with that name?
# and/or that no system user exists during install ?
- if not check_output(f"getent passwd {self.app} &>/dev/null || true").strip():
+ if os.system(f"getent passwd {self.app} >/dev/null 2>/dev/null") != 0:
# FIXME: improve logging ? os.system wont log stdout / stderr
- cmd = f"useradd --system --user-group {self.app}"
+ cmd = f"useradd --system --user-group {self.app} --home-dir {self.home} --no-create-home"
ret = os.system(cmd)
assert ret == 0, f"useradd command failed with exit code {ret}"
- if not check_output(f"getent passwd {self.app} &>/dev/null || true").strip():
+ if os.system(f"getent passwd {self.app} >/dev/null 2>/dev/null") != 0:
raise YunohostError(
f"Failed to create system user for {self.app}", raw_msg=True
)
+ # Update groups
groups = set(check_output(f"groups {self.app}").strip().split()[2:])
if self.allow_ssh:
@@ -461,17 +738,35 @@ class SystemuserAppResource(AppResource):
os.system(f"usermod -G {','.join(groups)} {self.app}")
+ # Update home dir
+ raw_user_line_in_etc_passwd = check_output(f"getent passwd {self.app}").strip()
+ user_infos = raw_user_line_in_etc_passwd.split(":")
+ current_home = user_infos[5]
+ if current_home != self.home:
+ ret = os.system(f"usermod --home {self.home} {self.app} 2>/dev/null")
+ # Most of the time this won't work because apparently we can't change the home dir while being logged-in -_-
+ # So we gotta brute force by replacing the line in /etc/passwd T_T
+ if ret != 0:
+ user_infos[5] = self.home
+ new_raw_user_line_in_etc_passwd = ":".join(user_infos)
+ os.system(
+ f"sed -i 's@{raw_user_line_in_etc_passwd}@{new_raw_user_line_in_etc_passwd}@g' /etc/passwd"
+ )
+
def deprovision(self, context: Dict = {}):
-
- if check_output(f"getent passwd {self.app} &>/dev/null || true").strip():
+ if os.system(f"getent passwd {self.app} >/dev/null 2>/dev/null") == 0:
os.system(f"deluser {self.app} >/dev/null")
- if check_output(f"getent passwd {self.app} &>/dev/null || true").strip():
- raise YunohostError(f"Failed to delete system user for {self.app}")
+ if os.system(f"getent passwd {self.app} >/dev/null 2>/dev/null") == 0:
+ raise YunohostError(
+ f"Failed to delete system user for {self.app}", raw_msg=True
+ )
- if check_output(f"getent group {self.app} &>/dev/null || true").strip():
+ if os.system(f"getent group {self.app} >/dev/null 2>/dev/null") == 0:
os.system(f"delgroup {self.app} >/dev/null")
- if check_output(f"getent group {self.app} &>/dev/null || true").strip():
- raise YunohostError(f"Failed to delete system user for {self.app}")
+ if os.system(f"getent group {self.app} >/dev/null 2>/dev/null") == 0:
+ raise YunohostError(
+ f"Failed to delete system user for {self.app}", raw_msg=True
+ )
# FIXME : better logging and error handling, add stdout/stderr from the deluser/delgroup commands...
@@ -480,28 +775,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__:rx`) The owner (and owner permissions) for 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
@@ -517,7 +812,7 @@ class InstalldirAppResource(AppResource):
default_properties: Dict[str, Any] = {
"dir": "/var/www/__APP__",
- "owner": "__APP__:rx",
+ "owner": "__APP__:rwx",
"group": "__APP__:rx",
}
@@ -525,10 +820,9 @@ class InstalldirAppResource(AppResource):
owner: str = ""
group: str = ""
- # FIXME: change default dir to /opt/stuff if app ain't a webapp ...
+ # FIXME: change default dir to /opt/stuff if app ain't a webapp...
def provision_or_update(self, context: Dict = {}):
-
assert self.dir.strip() # Be paranoid about self.dir being empty...
assert self.owner.strip()
assert self.group.strip()
@@ -551,11 +845,11 @@ class InstalldirAppResource(AppResource):
# and check for available space on the destination
if current_install_dir and os.path.isdir(current_install_dir):
logger.warning(
- f"Moving {current_install_dir} to {self.dir} ... (this may take a while)"
+ f"Moving {current_install_dir} to {self.dir}... (this may take a while)"
)
shutil.move(current_install_dir, self.dir)
else:
- mkdir(self.dir)
+ mkdir(self.dir, parents=True)
owner, owner_perm = self.owner.split(":")
group, group_perm = self.group.split(":")
@@ -582,7 +876,6 @@ class InstalldirAppResource(AppResource):
self.delete_setting("final_path") # Legacy
def deprovision(self, context: Dict = {}):
-
assert self.dir.strip() # Be paranoid about self.dir being empty...
assert self.owner.strip()
assert self.group.strip()
@@ -597,28 +890,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
- - `owner`: (default: `__APP__:rx`) The owner (and owner permissions) for 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
@@ -634,16 +929,17 @@ class DatadirAppResource(AppResource):
default_properties: Dict[str, Any] = {
"dir": "/home/yunohost.app/__APP__",
- "owner": "__APP__:rx",
+ "subdirs": [],
+ "owner": "__APP__:rwx",
"group": "__APP__:rx",
}
dir: str = ""
+ subdirs: list = []
owner: str = ""
group: str = ""
def provision_or_update(self, context: Dict = {}):
-
assert self.dir.strip() # Be paranoid about self.dir being empty...
assert self.owner.strip()
assert self.group.strip()
@@ -657,11 +953,16 @@ class DatadirAppResource(AppResource):
# FIXME: same as install_dir, is this what we want ?
if current_data_dir and os.path.isdir(current_data_dir):
logger.warning(
- f"Moving {current_data_dir} to {self.dir} ... (this may take a while)"
+ f"Moving {current_data_dir} to {self.dir}... (this may take a while)"
)
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(":")
@@ -681,12 +982,15 @@ 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
def deprovision(self, context: Dict = {}):
-
assert self.dir.strip() # Be paranoid about self.dir being empty...
assert self.owner.strip()
assert self.group.strip()
@@ -701,7 +1005,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"
@@ -712,14 +1016,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`
"""
@@ -734,22 +1040,31 @@ class AptDependenciesAppResource(AppResource):
default_properties: Dict[str, Any] = {"packages": [], "extras": {}}
packages: List = []
+ packages_from_raw_bash: str = ""
extras: Dict[str, Dict[str, str]] = {}
def __init__(self, properties: Dict[str, Any], *args, **kwargs):
-
for key, values in properties.get("extras", {}).items():
if not all(
isinstance(values.get(k), str) for k in ["repo", "key", "packages"]
):
raise YunohostError(
- "In apt resource in the manifest: 'extras' repo should have the keys 'repo', 'key' and 'packages' defined and be strings"
+ "In apt resource in the manifest: 'extras' repo should have the keys 'repo', 'key' and 'packages' defined and be strings",
+ raw_msg=True,
)
super().__init__(properties, *args, **kwargs)
- def provision_or_update(self, context: Dict = {}):
+ if self.packages_from_raw_bash:
+ out, err = self.check_output_bash_snippet(self.packages_from_raw_bash)
+ if err:
+ logger.error(
+ "Error while running apt resource packages_from_raw_bash snippet:"
+ )
+ logger.error(err)
+ self.packages += ", " + out.replace("\n", ", ")
+ def provision_or_update(self, context: Dict = {}):
script = [f"ynh_install_app_dependencies {self.packages}"]
for repo, values in self.extras.items():
script += [
@@ -760,7 +1075,6 @@ class AptDependenciesAppResource(AppResource):
self._run_script("provision_or_update", "\n".join(script))
def deprovision(self, context: Dict = {}):
-
self._run_script("deprovision", "ynh_remove_app_dependencies")
@@ -770,10 +1084,11 @@ 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.port]
- # (empty should be fine for most apps ... though you can customize stuff if absolutely needed)
+ [resources.ports]
+ # (empty should be fine for most apps... though you can customize stuff if absolutely needed)
+
main.default = 12345 # if you really want to specify a prefered value .. but shouldnt matter in the majority of cases
@@ -781,21 +1096,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.
"""
@@ -818,7 +1133,6 @@ class PortsResource(AppResource):
ports: Dict[str, Dict[str, Any]]
def __init__(self, properties: Dict[str, Any], *args, **kwargs):
-
if "main" not in properties:
properties["main"] = {}
@@ -832,22 +1146,19 @@ class PortsResource(AppResource):
super().__init__({"ports": properties}, *args, **kwargs)
def _port_is_used(self, port):
-
- # FIXME : this could be less brutal than two os.system ...
+ # FIXME : this could be less brutal than two os.system...
cmd1 = (
"ss --numeric --listening --tcp --udp | awk '{print$5}' | grep --quiet --extended-regexp ':%s$'"
% port
)
# This second command is mean to cover (most) case where an app is using a port yet ain't currently using it for some reason (typically service ain't up)
- cmd2 = f"grep --quiet \"port: '{port}'\" /etc/yunohost/apps/*/settings.yml"
- return os.system(cmd1) == 0 and os.system(cmd2) == 0
+ cmd2 = f"grep --quiet --extended-regexp \"port: '?{port}'?\" /etc/yunohost/apps/*/settings.yml"
+ return os.system(cmd1) == 0 or os.system(cmd2) == 0
def provision_or_update(self, context: Dict = {}):
-
from yunohost.firewall import firewall_allow, firewall_disallow
for name, infos in self.ports.items():
-
setting_name = f"port_{name}" if name != "main" else "port"
port_value = self.get_setting(setting_name)
if not port_value and name != "main":
@@ -865,7 +1176,8 @@ class PortsResource(AppResource):
if infos["fixed"]:
if self._port_is_used(port_value):
raise YunohostValidationError(
- f"Port {port_value} is already used by another process or app."
+ f"Port {port_value} is already used by another process or app.",
+ raw_msg=True,
)
else:
while self._port_is_used(port_value):
@@ -881,7 +1193,6 @@ class PortsResource(AppResource):
)
def deprovision(self, context: Dict = {}):
-
from yunohost.firewall import firewall_disallow
for name, infos in self.ports.items():
@@ -902,30 +1213,30 @@ 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`
"""
# Notes for future?
- # deep_clean -> ... idk look into any db name that would not be related to any app ...
+ # deep_clean -> ... idk look into any db name that would not be related to any app...
# backup -> dump db
# restore -> setup + inject db dump
@@ -938,7 +1249,6 @@ class DatabaseAppResource(AppResource):
}
def __init__(self, properties: Dict[str, Any], *args, **kwargs):
-
if "type" not in properties or properties["type"] not in [
"mysql",
"postgresql",
@@ -949,20 +1259,19 @@ class DatabaseAppResource(AppResource):
)
# Hack so that people can write type = "mysql/postgresql" in toml but it's loaded as dbtype
- # to avoid conflicting with the generic self.type of the resource object ...
+ # to avoid conflicting with the generic self.type of the resource object...
# dunno if that's really a good idea :|
properties = {"dbtype": properties["type"]}
super().__init__(properties, *args, **kwargs)
def db_exists(self, db_name):
-
if self.dbtype == "mysql":
- return os.system(f"mysqlshow '{db_name}' >/dev/null 2>/dev/null") == 0
+ return os.system(f"mysqlshow | grep -q -w '{db_name}' 2>/dev/null") == 0
elif self.dbtype == "postgresql":
return (
os.system(
- f"sudo --login --user=postgres psql -c '' '{db_name}' >/dev/null 2>/dev/null"
+ f"sudo --login --user=postgres psql '{db_name}' -c ';' >/dev/null 2>/dev/null"
)
== 0
)
@@ -970,7 +1279,6 @@ class DatabaseAppResource(AppResource):
return False
def provision_or_update(self, context: Dict = {}):
-
# This is equivalent to ynh_sanitize_dbid
db_name = self.app.replace("-", "_").replace(".", "_")
db_user = db_name
@@ -997,7 +1305,6 @@ class DatabaseAppResource(AppResource):
self.set_setting("db_pwd", db_pwd)
if not self.db_exists(db_name):
-
if self.dbtype == "mysql":
self._run_script(
"provision",
@@ -1010,7 +1317,6 @@ class DatabaseAppResource(AppResource):
)
def deprovision(self, context: Dict = {}):
-
db_name = self.app.replace("-", "_").replace(".", "_")
db_user = db_name
diff --git a/src/utils/system.py b/src/utils/system.py
index 8b0ed7092..a169bd62c 100644
--- a/src/utils/system.py
+++ b/src/utils/system.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -28,6 +28,10 @@ logger = logging.getLogger("yunohost.utils.packages")
YUNOHOST_PACKAGES = ["yunohost", "yunohost-admin", "moulinette", "ssowat"]
+def debian_version():
+ return check_output('grep "^VERSION_CODENAME=" /etc/os-release | cut -d= -f2')
+
+
def system_arch():
return check_output("dpkg --print-architecture")
@@ -49,7 +53,6 @@ def free_space_in_directory(dirpath):
def space_used_by_directory(dirpath, follow_symlinks=True):
-
if not follow_symlinks:
du_output = check_output(["du", "-sb", dirpath], shell=False)
return int(du_output.split()[0])
@@ -61,7 +64,6 @@ def space_used_by_directory(dirpath, follow_symlinks=True):
def human_to_binary(size: str) -> int:
-
symbols = ("K", "M", "G", "T", "P", "E", "Z", "Y")
factor = {}
for i, s in enumerate(symbols):
@@ -99,14 +101,12 @@ def binary_to_human(n: int) -> str:
def ram_available():
-
import psutil
return (psutil.virtual_memory().available, psutil.swap_memory().free)
def get_ynh_package_version(package):
-
# Returns the installed version and release version ('stable' or 'testing'
# or 'unstable')
@@ -152,7 +152,6 @@ def dpkg_lock_available():
def _list_upgradable_apt_packages():
-
# List upgradable packages
# LC_ALL=C is here to make sure the results are in english
upgradable_raw = check_output("LC_ALL=C apt list --upgradable")
@@ -162,7 +161,6 @@ def _list_upgradable_apt_packages():
line.strip() for line in upgradable_raw.split("\n") if line.strip()
]
for line in upgradable_raw:
-
# Remove stupid warning and verbose messages >.>
if "apt does not have a stable CLI interface" in line or "Listing..." in line:
continue
@@ -182,7 +180,6 @@ def _list_upgradable_apt_packages():
def _dump_sources_list():
-
from glob import glob
filenames = glob("/etc/apt/sources.list") + glob("/etc/apt/sources.list.d/*")
diff --git a/src/utils/yunopaste.py b/src/utils/yunopaste.py
index 0edcc721b..46131846d 100644
--- a/src/utils/yunopaste.py
+++ b/src/utils/yunopaste.py
@@ -1,5 +1,5 @@
#
-# Copyright (c) 2022 YunoHost Contributors
+# Copyright (c) 2023 YunoHost Contributors
#
# This file is part of YunoHost (see https://yunohost.org)
#
@@ -28,7 +28,6 @@ logger = logging.getLogger("yunohost.utils.yunopaste")
def yunopaste(data):
-
paste_server = "https://paste.yunohost.org"
try:
diff --git a/tox.ini b/tox.ini
index dc2c52074..49c78959d 100644
--- a/tox.ini
+++ b/tox.ini
@@ -8,7 +8,7 @@ deps =
py39-black-{run,check}: black
py39-mypy: mypy >= 0.900
commands =
- py39-lint: flake8 src doc maintenance tests --ignore E402,E501,E203,W503 --exclude src/vendor
+ py39-lint: flake8 src doc maintenance tests --ignore E402,E501,E203,W503,E741 --exclude src/vendor
py39-invalidcode: flake8 src bin maintenance --exclude src/tests,src/vendor --select F,E722,W605
py39-black-check: black --check --diff bin src doc maintenance tests
py39-black-run: black bin src doc maintenance tests