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 @@
![Version](https://img.shields.io/github/v/tag/yunohost/yunohost?label=version&sort=semver) -[![Build status](https://shields.io/gitlab/pipeline/yunohost/yunohost/dev)](https://gitlab.com/yunohost/yunohost/-/pipelines) -![Test coverage](https://img.shields.io/gitlab/coverage/yunohost/yunohost/dev) -[![Language grade: Python](https://img.shields.io/lgtm/grade/python/g/YunoHost/yunohost.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/YunoHost/yunohost/context:python) -[![GitHub license](https://img.shields.io/github/license/YunoHost/yunohost)](https://github.com/YunoHost/yunohost/blob/dev/LICENSE) +[![Pipeline status](https://gitlab.com/yunohost/yunohost/badges/dev/pipeline.svg)](https://gitlab.com/yunohost/yunohost/-/pipelines) +![Test coverage](https://gitlab.com/yunohost/yunohost/badges/dev/coverage.svg) +[![Project license](https://img.shields.io/gitlab/license/yunohost/yunohost)](https://github.com/YunoHost/yunohost/blob/dev/LICENSE) +[![CodeQL](https://github.com/yunohost/yunohost/workflows/CodeQL/badge.svg)](https://github.com/YunoHost/yunohost/security/code-scanning) [![Mastodon Follow](https://img.shields.io/mastodon/follow/28084)](https://mastodon.social/@yunohost)
diff --git a/conf/dovecot/dovecot.conf b/conf/dovecot/dovecot.conf index 72fd71c4d..e614c3796 100644 --- a/conf/dovecot/dovecot.conf +++ b/conf/dovecot/dovecot.conf @@ -10,7 +10,7 @@ mail_uid = 500 protocols = imap sieve {% if pop3_enabled == "True" %}pop3{% endif %} -mail_plugins = $mail_plugins quota +mail_plugins = $mail_plugins quota notify push_notification ############################################################################### diff --git a/conf/nginx/server.tpl.conf b/conf/nginx/server.tpl.conf index 40f85b328..d3ff77714 100644 --- a/conf/nginx/server.tpl.conf +++ b/conf/nginx/server.tpl.conf @@ -6,7 +6,7 @@ map $http_upgrade $connection_upgrade { server { listen 80; listen [::]:80; - server_name {{ domain }}{% if xmpp_enabled != "True" %} xmpp-upload.{{ domain }} muc.{{ domain }}{% endif %}; + server_name {{ domain }}{% if xmpp_enabled == "True" %} xmpp-upload.{{ domain }} muc.{{ domain }}{% endif %}; access_by_lua_file /usr/share/ssowat/access.lua; diff --git a/conf/nginx/yunohost_admin.conf.inc b/conf/nginx/yunohost_admin.conf.inc index 84c49d30b..0c4a96fdc 100644 --- a/conf/nginx/yunohost_admin.conf.inc +++ b/conf/nginx/yunohost_admin.conf.inc @@ -7,9 +7,11 @@ location /yunohost/admin/ { index index.html; {% if webadmin_allowlist_enabled == "True" %} - {% for ip in webadmin_allowlist.split(',') %} - allow {{ ip }}; - {% endfor %} + {% if webadmin_allowlist.strip() -%} + {% for ip in webadmin_allowlist.strip().split(',') -%} + allow {{ ip.strip() }}; + {% endfor -%} + {% endif -%} deny all; {% endif %} diff --git a/conf/postfix/plain/ldap-groups.cf b/conf/postfix/plain/ldap-groups.cf index dbf768641..215081fac 100644 --- a/conf/postfix/plain/ldap-groups.cf +++ b/conf/postfix/plain/ldap-groups.cf @@ -2,8 +2,6 @@ server_host = localhost server_port = 389 search_base = dc=yunohost,dc=org query_filter = (&(objectClass=groupOfNamesYnh)(mail=%s)) -exclude_internal = yes -search_timeout = 30 scope = sub result_attribute = memberUid, mail terminal_result_attribute = memberUid diff --git a/debian/changelog b/debian/changelog index 24a1969ed..9b61a7b45 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,338 @@ +yunohost (11.1.17) stable; urgency=low + + - domains: fix autodns for gandi root domain ([#1634](https://github.com/yunohost/yunohost/pull/1634)) + - helpers: fix previous change about using YNH_APP_ACTION ... which is not defined in config panel context (8c25aa9b) + - appsv2: for the dir/subdirs of data_dir, create parent folders if they don't exist (9a4267ff) + - quality: Split utils/config.py ([#1635](https://github.com/yunohost/yunohost/pull/1635)) + - quality: Rework questions/options tests ([#1629](https://github.com/yunohost/yunohost/pull/1629)) + + Thanks to all contributors <3 ! (axolotle, Kayou) + + -- Alexandre Aubin Wed, 05 Apr 2023 16:00:09 +0200 + +yunohost (11.1.16) stable; urgency=low + + - apps: fix i18n panel+section names ([#1630](https://github.com/yunohost/yunohost/pull/1630)) + - appsv2: don't remove yhh-deps virtual package if it doesn't exist. Otherwise when apt fails to install dependency, we end up with another error about failing to remove the ynh-deps package (3656c199) + - appsv2: add validation for expected types for permissions stuff (b2596f32) + - appsv2: add support for subdirs property in data_dir (4b46f322) + - appsv2: various fixes regarding sources toml parsing/caching (14bf2ee4) + - appsv2: add documentation about the new 'autoupdate' mechanism for app sources (63981aac) + - ynh_setup_source: fix buggy checksum mismatch handling, can't compute the sha256sum after we delete the file @_@ (1b2fa91f) + - users: fix quota parsing being wrong by a factor 1000 ... doveadm returns kilos, not bytes (821aedef) + - backup: fix boring issue where archive is a broken symlink... (a95d10e5) + + Thanks to all contributors <3 ! (axolotle) + + -- Alexandre Aubin Sun, 02 Apr 2023 20:29:33 +0200 + +yunohost (11.1.15) stable; urgency=low + + - doc: Fix version number in autogenerated resource doc (5b58e0e6) + - helpers: Fix documentation for ynh_setup_source (7491dd4c) + - helpers: fix ynh_setup_source, 'source_id' may contain slashes x_x (eaf7a290) + - helpers/nodejs: simplify 'n' script install and maintenance ([#1627](https://github.com/yunohost/yunohost/pull/1627)) + + -- Alexandre Aubin Sat, 11 Mar 2023 16:50:50 +0100 + +yunohost (11.1.14) stable; urgency=low + + - helpers: simplify --time display option for ynh_script_progression .. we don't care about displaying time when below 10 sc (8731f77a) + - appsv2: add support for a 'sources' app resources to modernize and replace app.src format ([#1615](https://github.com/yunohost/yunohost/pull/1615)) + - i18n: Translations updated for Arabic, Polish, Ukrainian + + Thanks to all contributors <3 ! (ButterflyOfFire, Grzegorz Cichocki, Tymofii-Lytvynenko) + + -- Alexandre Aubin Thu, 09 Mar 2023 15:34:17 +0100 + +yunohost (11.1.13) stable; urgency=low + + - appsv2: fix port already used detection ([#1622](https://github.com/yunohost/yunohost/pull/1622)) + - appsv2: when hydrating template, the data may be not-string, eg ports are int (72986842) + - [i18n] Translations updated for Arabic, French, Galician, German, Occitan + + Thanks to all contributors <3 ! (ButterflyOfFire, Christian Wehrli, José M, Kay0u, ppr) + + -- Alexandre Aubin Fri, 03 Mar 2023 22:57:14 +0100 + +yunohost (11.1.12.2) stable; urgency=low + + - helpers: omg base64 wraps the output by default :| (d04f2085) + + -- Alexandre Aubin Wed, 01 Mar 2023 22:12:51 +0100 + +yunohost (11.1.12.1) stable; urgency=low + + - helper: fix previous tweak about debugging diff for manually modified files on the CI @_@ (fd304008) + + -- Alexandre Aubin Wed, 01 Mar 2023 08:08:55 +0100 + +yunohost (11.1.12) stable; urgency=low + + - apps: add '--continue-on-failure' to 'yunohost app upgrade ([#1602](https://github.com/yunohost/yunohost/pull/1602)) + - appsv2: Create parent dirs when provisioning install_dir ([#1609](https://github.com/yunohost/yunohost/pull/1609)) + - appsv2: set `w` as default permission on `install_dir` folder ([#1611](https://github.com/yunohost/yunohost/pull/1611)) + - appsv2: Handle undefined main permission url ([#1620](https://github.com/yunohost/yunohost/pull/1620)) + - apps/helpers: tweak behavior of checksum helper in CI context to help debug why file appear as 'manually modified' ([#1618](https://github.com/yunohost/yunohost/pull/1618)) + - apps/helpers: more robust way to grep that the service correctly started ? ([#1617](https://github.com/yunohost/yunohost/pull/1617)) + - regenconf: sometimes ntp doesnt exist (97c0128c) + - nginx/security: fix empty webadmin allowlist breaking nginx conf... (e458d881) + - misc: automatic get rid of /etc/profile.d/check_yunohost_is_installed.sh when yunohost is postinstalled (20e8805e) + - settings: Fix pop3_enabled parsing returning 0/1 instead of True/False ... (b40c0de3) + - [i18n] Translations updated for French, Galician, Italian, Polish + + Thanks to all contributors <3 ! (Éric Gaspar, John Schmidt, José M, Krakinou, Kuba Bazan, Laurent Peuch, ppr, tituspijean) + + -- Alexandre Aubin Tue, 28 Feb 2023 23:08:02 +0100 + +yunohost (11.1.11.2) stable; urgency=low + + - Rebump version to flag as stable, not testing >_> + + -- Alexandre Aubin Fri, 24 Feb 2023 13:09:48 +0100 + +yunohost (11.1.11.1) testing; urgency=low + + - appsv2: fix previous commit about __DOMAIN__ because url may be None x_x (e05df676) + + -- Alexandre Aubin Fri, 24 Feb 2023 01:30:14 +0100 + +yunohost (11.1.11) stable; urgency=low + + - logs: fix decoding errors not handled when trying to read service logs ([#1606](https://github.com/yunohost/yunohost/pull/1606)) + - mail: fix dovecot-pop3d not being installed when enabling pop3 ([#1607](https://github.com/yunohost/yunohost/pull/1607)) + - apps: when creating the app's bash env for script, make sure to use the manifest from the workdir instead of app setting dir, which is important for consistency during edge case when upgrade from v1 to v2 fails (bab27014) + - appsv2: data_dir's owner should have rwx by default (139e54a2) + - appsv2: fix usage of __DOMAIN__ in permission url (943b9ff8) + + Thanks to all contributors <3 ! (Eric Geldmacher, ljf) + + -- Alexandre Aubin Thu, 23 Feb 2023 22:31:02 +0100 + +yunohost (11.1.10) stable; urgency=low + + - apps: add 'YNH_DEBIAN_VERSION' variable in apps contexts (df6a2a2c) + - appsv2: add support for a packages_from_raw_bash option in apt where one can add a multiline bash snippet to echo packages (4dfff201) + - appsv2: fix resource provisioning scripts picking up already-closed operation logger, resulting in confusing debugging output (888593ad) + - appsv2: fix reload_only_if_change option not working as expected, resulting in incorrect 'Firewall reloaded' messages (d725b454) + - appsv2: fix check that postgresql db exists... (1dc8b753) + + -- Alexandre Aubin Tue, 21 Feb 2023 18:57:33 +0100 + +yunohost (11.1.9) stable; urgency=low + + - apps: simplify the redaction of change_url scripts by adding a new ynh_change_url_nginx_config helper + predefining new/old/change domain/path variables (2b70ccbf) + - appsv2: revert commit that adds a bunch of warning about apt/database consistency, it's more relevant to have them in package linter instead (63f0f084) + - appsv2: fix system user group update, broke in commit from earlier (ec4c2684) + - log: Previous trick about getting rid of setting didnt work, forgot to use metadata instead of self.metadata (848adf89) + - ux: Moar boring postgresql messages displayed as warning (290d627f) + + Thanks to all contributors <3 ! (Bram) + + -- Alexandre Aubin Mon, 20 Feb 2023 20:32:28 +0100 + +yunohost (11.1.8.2) stable; urgency=low + + - regenconf: fix undefined var in apt regenconf (343065eb) + + -- Alexandre Aubin Sun, 19 Feb 2023 21:38:59 +0100 + +yunohost (11.1.8.1) stable; urgency=low + + - postgresql: moar regenconf fixes (e6ae3892) + - postgresql: ugly hack to hide boring warning messages when installing postgresql with apt the first time ... (13d50f4f) + + -- Alexandre Aubin Sun, 19 Feb 2023 19:41:05 +0100 + +yunohost (11.1.8) stable; urgency=low + + - apps: don't miserably crash when failing to read .md file such as DESCRIPTION.md (58ac633d) + - apps: fix edge case when upgrading using a local folder not modified since a while (d3ec5d05) + - appsv2: fix system user provisioning ... (d123fd76, 771b801e) + - appsv2: add check about database vs. apt consistency in resource / warn about lack of explicit dependency to mariadb-server (97b69e7c) + - appsv2: add home dir that defaults to /var/www/__APP__ for system user resource (ce7227c0) + - postgresql: fix regenconf hook, the arg format thingy changed a bit at some point ? (8a43b046) + - regenconf: in apt/php stuff, don't try to upgrade-alternatives if the default PHP version ain't available anymore (similar to commit e24ddd29) (18e034df) + - postinstall: raise a proper error when trying to use e.g. 'admin' as the first username which will conflict with the admins group mail aliases (475c93d5) + - i18n: Translations updated for Arabic, Basque + + Thanks to all contributors <3 ! (ButterflyOfFire, xabirequejo) + + -- Alexandre Aubin Sun, 19 Feb 2023 18:22:02 +0100 + +yunohost (11.1.7) stable; urgency=low + + - mail: fix complain about unused parameters in postfix: exclude_internal=yes / search_timeout=30 (0da6370d) + - mail: Add push notification plugins in dovecot ([#1594](https://github.com/yunohost/yunohost/pull/1594)) + - diagnosis: fix typo, diagnosis detail should be a list, not a string (d0ca120e) + - helpers: in apt/php stuff, don't try to upgrade-alternatives if the default PHP version ain't available anymore (e24ddd29) + - apps: fix inconsistent app removal during remove-after-failed-upgrade and remove-after-failed-backup contexts (7be7eb11) + - appsv2: we don't want to store user-provided passwords by default, but they should still be set in the env for the script to use it (9bd4344f) + - appsv2: fix i18n for arch mismatch, can't juste join() inside string formated with .format() (aa9bc47a) + - appsv2: missing raw_msg=True for exceptions (1d1a3756) + - appsv2: fix check that main permission url is '/' (ab8a6b94) + - appsv2: mysqlshow is fucking dumb and returns exit code 0 when DB doesnt exists ... (0ab20b73) + - appsv2: also replace __DOMAIN__ in resource properties (0c4a006a) + - appsv2: in php helpers, use the global $phpversion var/setting by default instead of $YNH_PHP_VERSION (60b21795) + - i18n: Translations updated for Arabic, Galician + + Thanks to all contributors <3 ! (ButterflyOfFire, John Hackett, José M) + + -- Alexandre Aubin Wed, 15 Feb 2023 21:08:04 +0100 + +yunohost (11.1.6.2) stable; urgency=low + + - permissions: fix trailing-slash issue in edge case where app has additional urls related to a different domain (a4fa6e07) + - backup: fix postinstall during full restore ... tmp admin user can't be named 'admin' because of conflicting alias with the admins group (65894007) + - doc: improve app resource doc (a154e811) + + -- Alexandre Aubin Thu, 09 Feb 2023 19:00:42 +0100 + +yunohost (11.1.6.1) stable; urgency=low + + - dns: fix CAA recommended DNS conf -> 0 is apparently a more sensible value than 128... (2eb7da06) + - users: Allow digits in user fullname (024db62a) + - backup: fix full backup restore postinstall calls that now need first username+fullname+password (48e488f8) + - i18n: Translations updated for Arabic, Basque, Chinese (Simplified) + + Thanks to all contributors <3 ! (ButterflyOfFire, Poesty Li, xabirequejo) + + -- Alexandre Aubin Wed, 08 Feb 2023 22:50:37 +0100 + +yunohost (11.1.6) stable; urgency=low + + - helpers: allow to use ynh_replace_string with @ ([#1588](https://github.com/yunohost/yunohost/pull/1588)) + - helpers: fix behavior of ynh_write_var_in_file when key is duplicated ([#1589](https://github.com/yunohost/yunohost/pull/1589), [#1591](https://github.com/yunohost/yunohost/pull/1591)) + - helpers: fix composer workdir variable for package v2 ([#1586](https://github.com/yunohost/yunohost/pull/1586)) + - configpanels: properly escape & for values used in ynh_write_var_in_file ([#1590](https://github.com/yunohost/yunohost/pull/1590)) + - appsv2/group question: don't include primary groups in choices (c179d4b8) + - appsv2: when initalizing permission, make sure to add 'all_users' when visitors is chosen (71042f08) + - backup/multimedia: test that /home/yunohots.multimedia does exists to avoid boring warning later (170eaf5d) + - domains: add missing logic to inject translated 'help' keys in config panel like we do for global settings (4dee434e) + - domain/dns: don't miserably crash when the domain is known by lexicon but not in registrar_list.toml (b5b69e95) + - admin->admins migration: try to losen up even more the search for first admin user x_x (1e520342) + - i18n: Translations updated for French, Polish + + Thanks to all contributors <3 ! (Éric Gaspar, Grzegorz Cichocki, Kayou, Krzysztof Nowakowski, ljf, ppr) + + -- Alexandre Aubin Tue, 07 Feb 2023 00:14:17 +0100 + +yunohost (11.1.5.5) stable; urgency=low + + - admin->admins migration: try to handle boring case where the 'first' user cant be identified because it doesnt have the root@ alias (8485ebc7) + - appsv2: ignore the old/ugly/legacy removal of apt deps when removing the php conf, because that's handled by the apt resource (3bbba640) + - appsv2: moar fixes for v1->v2 upgrade not getting the proper env context (fb54da2e) + + -- Alexandre Aubin Sat, 04 Feb 2023 18:51:03 +0100 + +yunohost (11.1.5.4) stable; urgency=low + + - appsv2: typo in ports resource doc x_x (0e787acb) + - appsv2: fix permission provisioning for fulldomain apps + fix apps not properly getting removed after failed resources init (476908bd) + + -- Alexandre Aubin Fri, 03 Feb 2023 20:43:04 +0100 + +yunohost (11.1.5.3) stable; urgency=low + + - helpers/appsv2: replacement of __PHPVERSION__ should use the phpversion setting, not YNH_PHP_VERSION (13d4e16e) + - appv2 resources: document the fact that the apt resource may create a phpversion setting when the dependencies contain php packages (2107a848) + + -- Alexandre Aubin Fri, 03 Feb 2023 03:05:11 +0100 + +yunohost (11.1.5.2) stable; urgency=low + + - maintenance: new year, update copyright header (ba4f1925) + - helpers: fix remaining __FINALPATH__ in php template (note that this is backward compatible because ynh_add_config will replace __INSTALL_DIR__ by $finalpath if $finalpath exists... (9b7668da) + + -- Alexandre Aubin Thu, 02 Feb 2023 23:58:29 +0100 + +yunohost (11.1.5.1) stable; urgency=low + + - debian: Bump moulinette/ssowat requirement to 11.1 (0826a541) + - helpers: Fixes $app unbound when running ynh_secure_remove ([#1582](https://github.com/yunohost/yunohost/pull/1582)) + - log/appv2: don't dump all settings in log metadata (a9ac55e4) + - appv2: resource upgrade will tweak settings, we have to re-update the env_dict after upgrading resources (3110460a) + - appv2: safety-backup-before-upgrade should only contain the app (1c95bcff) + - appv2: fix env not including vars for v1->v2 upgrade (2b2d49a5) + - backup: add name of the backup in create/delete message, otherwise that creates some spooky messages with 'Backup created' directly followed by 'Backup deleted' during safety-backup-before-upgrade in v2 apps (8090acb1) + - [i18n] Translations updated for Arabic, French, Galician, Polish + + Thanks to all contributors <3 ! (ButterflyOfFire, Éric Gaspar, Eryk Michalak, Florent, José M, ppr) + + -- Alexandre Aubin Thu, 02 Feb 2023 23:37:46 +0100 + +yunohost (11.1.5) stable; urgency=low + + - Release as stable ! + + - diagnosis: we can't yield an ERROR if there's no IPv6, otherwise that blocks all subsequent network-related diagnoser because of the dependency system ... (ade92e43) + - domains: fix domain_config.toml typos in conditions (480f7a43) + - certs: Don't try restarting metronome if no domain configured for it (452ba8bb) + + Thanks to all contributors <3 ! (Axolotle) + + -- Alexandre Aubin Wed, 01 Feb 2023 20:21:56 +0100 + +yunohost (11.1.4.1) testing; urgency=low + + - debian: don't dump upgradable apps during postinst's catalog update (82d30f02) + - ynh_setup_source: Output checksums when source is 'corrupt' ([#1578](https://github.com/yunohost/yunohost/pull/1578)) + - metronome: Auto-enable/disable metronome if there's no/at least one domain configured for XMPP (c990cee6) + + Thanks to all contributors <3 ! (tituspijean) + + -- Alexandre Aubin Wed, 01 Feb 2023 17:10:32 +0100 + +yunohost (11.1.4) testing; urgency=low + + - settings: Add DNS exposure setting given the IP version ([#1451](https://github.com/yunohost/yunohost/pull/1451)) + + Thanks to all contributors <3 ! (Tagada) + + -- Alexandre Aubin Mon, 30 Jan 2023 16:28:56 +0100 + +yunohost (11.1.3.1) testing; urgency=low + + - nginx: add xmpp-upload. and muc. server_name only if xmpp_enabled is enabled (c444dee4) + - [i18n] Translations updated for Arabic, Basque, French, Galician, Spanish, Turkish + + Thanks to all contributors <3 ! (Alperen İsa Nalbant, ButterflyOfFire, cristian amoyao, Éric Gaspar, José M, Kayou, ppr, quiwy, xabirequejo) + + -- Alexandre Aubin Mon, 30 Jan 2023 15:44:30 +0100 + +yunohost (11.1.3) testing; urgency=low + + - helpers: Include procedures in MySQL database backup ([#1570](https://github.com/yunohost/yunohost/pull/1570)) + - users: be able to change the loginShell of a user ([#1538](https://github.com/yunohost/yunohost/pull/1538)) + - debian: refresh catalog upon package upgrade (be5b1c1b) + + Thanks to all contributors <3 ! (Éric Gaspar, Kay0u, ljf, Metin Bektas) + + -- Alexandre Aubin Thu, 19 Jan 2023 23:08:10 +0100 + +yunohost (11.1.2.2) testing; urgency=low + + - Minor technical fixes (b37d4baf, 68342171) + - configpanel: stop the madness of returning a 500 error when trying to load config panel 0.1 ... otherwise this will crash the new app info view ... (f21fbed2) + - apps: fix trick to find the default branch from git repo @_@ (25c10166) + - debian: regen ssowatconf during package upgrade (4615d7b7) + - [i18n] Translations updated for French + + Thanks to all contributors <3 ! (Éric Gaspar, ppr) + + -- Alexandre Aubin Tue, 10 Jan 2023 13:23:28 +0100 + +yunohost (11.1.2.1) testing; urgency=low + + - i18n: fix (un)defined string issues (dd33476f) + - doc: Revive the old auto documentation of API with swagger + - apps: don't clone 'master' branch by default, use git ls-remote to check what's the default branch instead (a6db52b7) + - ssowat: add use_remote_user_var_in_nginx_conf flag on permission (f258eab6) + + Thanks to all contributors <3 ! (ljf) + + -- Alexandre Aubin Mon, 09 Jan 2023 23:58:51 +0100 + yunohost (11.1.2) testing; urgency=low - apps: Various fixes/improvements for appsv2, mostly related to webadmin integration ([#1526](https://github.com/yunohost/yunohost/pull/1526)) diff --git a/debian/control b/debian/control index facedbff2..0258eaac7 100644 --- a/debian/control +++ b/debian/control @@ -10,7 +10,7 @@ Package: yunohost Essential: yes Architecture: all Depends: ${python3:Depends}, ${misc:Depends} - , moulinette (>= 11.0), ssowat (>= 11.0) + , moulinette (>= 11.1), ssowat (>= 11.1) , python3-psutil, python3-requests, python3-dnspython, python3-openssl , python3-miniupnpc, python3-dbus, python3-jinja2 , python3-toml, python3-packaging, python3-publicsuffix2 diff --git a/debian/postinst b/debian/postinst index 9fb9b9977..238817cd7 100644 --- a/debian/postinst +++ b/debian/postinst @@ -20,6 +20,7 @@ do_configure() { fi else echo "Regenerating configuration, this might take a while..." + yunohost app ssowatconf yunohost tools regen-conf --output-as none echo "Launching migrations..." @@ -27,6 +28,9 @@ do_configure() { echo "Re-diagnosing server health..." yunohost diagnosis run --force + + echo "Refreshing app catalog..." + yunohost tools update apps --output-as none || true fi # Trick to let yunohost handle the restart of the API, @@ -52,7 +56,6 @@ API_START_TIMESTAMP="\$(date --date="\$(systemctl show yunohost-api | grep ExecM if [ "\$(( \$(date +%s) - \$API_START_TIMESTAMP ))" -ge 60 ]; then - echo "restart" >> /var/log/testalex systemctl restart yunohost-api fi EOF diff --git a/doc/generate_api_doc.py b/doc/generate_api_doc.py index 939dd90bd..bb5f1df29 100644 --- a/doc/generate_api_doc.py +++ b/doc/generate_api_doc.py @@ -22,291 +22,262 @@ import os import sys import yaml import json -import requests + def main(): - """ - """ - with open('../share/actionsmap.yml') as f: + with open("../share/actionsmap.yml") as f: action_map = yaml.safe_load(f) - try: - with open('/etc/yunohost/current_host', 'r') as f: - domain = f.readline().rstrip() - except IOError: - domain = requests.get('http://ip.yunohost.org').text - with open('../debian/changelog') as f: + # try: + # with open("/etc/yunohost/current_host", "r") as f: + # domain = f.readline().rstrip() + # except IOError: + # domain = requests.get("http://ip.yunohost.org").text + + with open("../debian/changelog") as f: top_changelog = f.readline() - api_version = top_changelog[top_changelog.find("(")+1:top_changelog.find(")")] + api_version = top_changelog[top_changelog.find("(") + 1 : top_changelog.find(")")] csrf = { - 'name': 'X-Requested-With', - 'in': 'header', - 'required': True, - 'schema': { - 'type': 'string', - 'default': 'Swagger API' - } - + "name": "X-Requested-With", + "in": "header", + "required": True, + "schema": {"type": "string", "default": "Swagger API"}, } resource_list = { - 'openapi': '3.0.3', - 'info': { - 'title': 'YunoHost API', - 'description': 'This is the YunoHost API used on all YunoHost instances. This API is essentially used by YunoHost Webadmin.', - 'version': api_version, - + "openapi": "3.0.3", + "info": { + "title": "YunoHost API", + "description": "This is the YunoHost API used on all YunoHost instances. This API is essentially used by YunoHost Webadmin.", + "version": api_version, }, - 'servers': [ + "servers": [ { - 'url': "https://{domain}/yunohost/api", - 'variables': { - 'domain': { - 'default': 'demo.yunohost.org', - 'description': 'Your yunohost domain' + "url": "https://{domain}/yunohost/api", + "variables": { + "domain": { + "default": "demo.yunohost.org", + "description": "Your yunohost domain", } - } + }, } ], - 'tags': [ - { - 'name': 'public', - 'description': 'Public route' - } - ], - 'paths': { - '/login': { - 'post': { - 'tags': ['public'], - 'summary': 'Logs in and returns the authentication cookie', - 'parameters': [csrf], - 'requestBody': { - 'required': True, - 'content': { - 'multipart/form-data': { - 'schema': { - 'type': 'object', - 'properties': { - 'credentials': { - 'type': 'string', - 'format': 'password' + "tags": [{"name": "public", "description": "Public route"}], + "paths": { + "/login": { + "post": { + "tags": ["public"], + "summary": "Logs in and returns the authentication cookie", + "parameters": [csrf], + "requestBody": { + "required": True, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "credentials": { + "type": "string", + "format": "password", } }, - 'required': [ - 'credentials' - ] + "required": ["credentials"], } } + }, + }, + "security": [], + "responses": { + "200": { + "description": "Successfully login", + "headers": {"Set-Cookie": {"schema": {"type": "string"}}}, } }, - 'security': [], - 'responses': { - '200': { - 'description': 'Successfully login', - 'headers': { - 'Set-Cookie': { - 'schema': { - 'type': 'string' - } - } - } - } - } } }, - '/installed': { - 'get': { - 'tags': ['public'], - 'summary': 'Test if the API is working', - 'parameters': [], - 'security': [], - 'responses': { - '200': { - 'description': 'Successfully working', + "/installed": { + "get": { + "tags": ["public"], + "summary": "Test if the API is working", + "parameters": [], + "security": [], + "responses": { + "200": { + "description": "Successfully working", } - } + }, } - } + }, }, } - def convert_categories(categories, parent_category=""): for category, category_params in categories.items(): if parent_category: category = f"{parent_category} {category}" - if 'subcategory_help' in category_params: - category_params['category_help'] = category_params['subcategory_help'] + if "subcategory_help" in category_params: + category_params["category_help"] = category_params["subcategory_help"] - if 'category_help' not in category_params: - category_params['category_help'] = '' - resource_list['tags'].append({ - 'name': category, - 'description': category_params['category_help'] - }) + if "category_help" not in category_params: + category_params["category_help"] = "" + resource_list["tags"].append( + {"name": category, "description": category_params["category_help"]} + ) - - for action, action_params in category_params['actions'].items(): - if 'action_help' not in action_params: - action_params['action_help'] = '' - if 'api' not in action_params: + for action, action_params in category_params["actions"].items(): + if "action_help" not in action_params: + action_params["action_help"] = "" + if "api" not in action_params: continue - if not isinstance(action_params['api'], list): - action_params['api'] = [action_params['api']] + if not isinstance(action_params["api"], list): + action_params["api"] = [action_params["api"]] - for i, api in enumerate(action_params['api']): + for i, api in enumerate(action_params["api"]): print(api) - method, path = api.split(' ') + method, path = api.split(" ") method = method.lower() - key_param = '' - if '{' in path: - key_param = path[path.find("{")+1:path.find("}")] - resource_list['paths'].setdefault(path, {}) + key_param = "" + if "{" in path: + key_param = path[path.find("{") + 1 : path.find("}")] + resource_list["paths"].setdefault(path, {}) - notes = '' + notes = "" operationId = f"{category}_{action}" if i > 0: operationId += f"_{i}" operation = { - 'tags': [category], - 'operationId': operationId, - 'summary': action_params['action_help'], - 'description': notes, - 'responses': { - '200': { - 'description': 'successful operation' - } - } + "tags": [category], + "operationId": operationId, + "summary": action_params["action_help"], + "description": notes, + "responses": {"200": {"description": "successful operation"}}, } - if action_params.get('deprecated'): - operation['deprecated'] = True + if action_params.get("deprecated"): + operation["deprecated"] = True - operation['parameters'] = [] - if method == 'post': - operation['parameters'] = [csrf] + operation["parameters"] = [] + if method == "post": + operation["parameters"] = [csrf] - if 'arguments' in action_params: - if method in ['put', 'post', 'patch']: - operation['requestBody'] = { - 'required': True, - 'content': { - 'multipart/form-data': { - 'schema': { - 'type': 'object', - 'properties': { - }, - 'required': [] + if "arguments" in action_params: + if method in ["put", "post", "patch"]: + operation["requestBody"] = { + "required": True, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": {}, + "required": [], } } - } + }, } - for arg_name, arg_params in action_params['arguments'].items(): - if 'help' not in arg_params: - arg_params['help'] = '' - param_type = 'query' + for arg_name, arg_params in action_params["arguments"].items(): + if "help" not in arg_params: + arg_params["help"] = "" + param_type = "query" allow_multiple = False required = True allowable_values = None - name = str(arg_name).replace('-', '_') - if name[0] == '_': + name = str(arg_name).replace("-", "_") + if name[0] == "_": required = False - if 'full' in arg_params: - name = arg_params['full'][2:] + if "full" in arg_params: + name = arg_params["full"][2:] else: name = name[2:] - name = name.replace('-', '_') + name = name.replace("-", "_") - if 'choices' in arg_params: - allowable_values = arg_params['choices'] - _type = 'string' - if 'type' in arg_params: - types = { - 'open': 'file', - 'int': 'int' - } - _type = types[arg_params['type']] - if 'action' in arg_params and arg_params['action'] == 'store_true': - _type = 'boolean' + if "choices" in arg_params: + allowable_values = arg_params["choices"] + _type = "string" + if "type" in arg_params: + types = {"open": "file", "int": "int"} + _type = types[arg_params["type"]] + if ( + "action" in arg_params + and arg_params["action"] == "store_true" + ): + _type = "boolean" - if 'nargs' in arg_params: - if arg_params['nargs'] == '*': + if "nargs" in arg_params: + if arg_params["nargs"] == "*": allow_multiple = True required = False - _type = 'array' - if arg_params['nargs'] == '+': + _type = "array" + if arg_params["nargs"] == "+": allow_multiple = True required = True - _type = 'array' - if arg_params['nargs'] == '?': + _type = "array" + if arg_params["nargs"] == "?": allow_multiple = False required = False else: allow_multiple = False - if name == key_param: - param_type = 'path' + param_type = "path" required = True allow_multiple = False - if method in ['put', 'post', 'patch']: - schema = operation['requestBody']['content']['multipart/form-data']['schema'] - schema['properties'][name] = { - 'type': _type, - 'description': arg_params['help'] + if method in ["put", "post", "patch"]: + schema = operation["requestBody"]["content"][ + "multipart/form-data" + ]["schema"] + schema["properties"][name] = { + "type": _type, + "description": arg_params["help"], } if required: - schema['required'].append(name) - prop_schema = schema['properties'][name] + schema["required"].append(name) + prop_schema = schema["properties"][name] else: parameters = { - 'name': name, - 'in': param_type, - 'description': arg_params['help'], - 'required': required, - 'schema': { - 'type': _type, + "name": name, + "in": param_type, + "description": arg_params["help"], + "required": required, + "schema": { + "type": _type, }, - 'explode': allow_multiple + "explode": allow_multiple, } - prop_schema = parameters['schema'] - operation['parameters'].append(parameters) + prop_schema = parameters["schema"] + operation["parameters"].append(parameters) if allowable_values is not None: - prop_schema['enum'] = allowable_values - if 'default' in arg_params: - prop_schema['default'] = arg_params['default'] - if arg_params.get('metavar') == 'PASSWORD': - prop_schema['format'] = 'password' - if arg_params.get('metavar') == 'MAIL': - prop_schema['format'] = 'mail' + prop_schema["enum"] = allowable_values + if "default" in arg_params: + prop_schema["default"] = arg_params["default"] + if arg_params.get("metavar") == "PASSWORD": + prop_schema["format"] = "password" + if arg_params.get("metavar") == "MAIL": + prop_schema["format"] = "mail" # Those lines seems to slow swagger ui too much - #if 'pattern' in arg_params.get('extra', {}): + # if 'pattern' in arg_params.get('extra', {}): # prop_schema['pattern'] = arg_params['extra']['pattern'][0] - - - resource_list['paths'][path][method.lower()] = operation + resource_list["paths"][path][method.lower()] = operation # Includes subcategories - if 'subcategories' in category_params: - convert_categories(category_params['subcategories'], category) + if "subcategories" in category_params: + convert_categories(category_params["subcategories"], category) - del action_map['_global'] + del action_map["_global"] convert_categories(action_map) openapi_json = json.dumps(resource_list) # Save the OpenAPI json - with open(os.getcwd() + '/openapi.json', 'w') as f: + with open(os.getcwd() + "/openapi.json", "w") as f: f.write(openapi_json) openapi_js = f"var openapiJSON = {openapi_json}" - with open(os.getcwd() + '/openapi.js', 'w') as f: + with open(os.getcwd() + "/openapi.js", "w") as f: f.write(openapi_js) - -if __name__ == '__main__': +if __name__ == "__main__": sys.exit(main()) diff --git a/doc/generate_bash_completion.py b/doc/generate_bash_completion.py index d55973010..88aa273fd 100644 --- a/doc/generate_bash_completion.py +++ b/doc/generate_bash_completion.py @@ -31,7 +31,6 @@ def get_dict_actions(OPTION_SUBTREE, category): with open(ACTIONSMAP_FILE, "r") as stream: - # Getting the dictionary containning what actions are possible per category OPTION_TREE = yaml.safe_load(stream) @@ -65,7 +64,6 @@ with open(ACTIONSMAP_FILE, "r") as stream: os.makedirs(BASH_COMPLETION_FOLDER, exist_ok=True) with open(BASH_COMPLETION_FILE, "w") as generated_file: - # header of the file generated_file.write("#\n") generated_file.write("# completion for yunohost\n") diff --git a/doc/generate_helper_doc.py b/doc/generate_helper_doc.py index 525482596..110d1d4cd 100644 --- a/doc/generate_helper_doc.py +++ b/doc/generate_helper_doc.py @@ -20,12 +20,11 @@ def get_current_commit(): def render(helpers): - current_commit = get_current_commit() data = { "helpers": helpers, - "date": datetime.datetime.now().strftime("%m/%d/%Y"), + "date": datetime.datetime.now().strftime("%d/%m/%Y"), "version": open("../debian/changelog").readlines()[0].split()[1].strip("()"), } @@ -56,20 +55,17 @@ def render(helpers): class Parser: def __init__(self, filename): - self.filename = filename self.file = open(filename, "r").readlines() self.blocks = None def parse_blocks(self): - self.blocks = [] current_reading = "void" current_block = {"name": None, "line": -1, "comments": [], "code": []} for i, line in enumerate(self.file): - if line.startswith("#!/bin/bash"): continue @@ -117,7 +113,6 @@ class Parser: current_reading = "code" elif current_reading == "code": - if line == "}": # We're getting out of the function current_reading = "void" @@ -138,7 +133,6 @@ class Parser: continue def parse_block(self, b): - b["brief"] = "" b["details"] = "" b["usage"] = "" @@ -164,7 +158,6 @@ class Parser: elif subblock.startswith("usage"): for line in subblock.split("\n"): - if line.startswith("| arg"): linesplit = line.split() argname = linesplit[2] @@ -216,7 +209,6 @@ def malformed_error(line_number): def main(): - helper_files = sorted(glob.glob("../helpers/*")) helpers = [] diff --git a/doc/generate_manpages.py b/doc/generate_manpages.py index bdb1fcaee..782dd8a90 100644 --- a/doc/generate_manpages.py +++ b/doc/generate_manpages.py @@ -60,7 +60,6 @@ def main(): # man pages of "yunohost *" with open(ACTIONSMAP_FILE, "r") as actionsmap: - # Getting the dictionary containning what actions are possible per domain actionsmap = ordered_yaml_load(actionsmap) diff --git a/doc/generate_resource_doc.py b/doc/generate_resource_doc.py index 1e16a76d9..201d25265 100644 --- a/doc/generate_resource_doc.py +++ b/doc/generate_resource_doc.py @@ -1,12 +1,72 @@ -from yunohost.utils.resources import AppResourceClassesByType +import ast +import datetime +import subprocess -resources = sorted(AppResourceClassesByType.values(), key=lambda r: r.priority) +version = open("../debian/changelog").readlines()[0].split()[1].strip("()") +today = datetime.datetime.now().strftime("%d/%m/%Y") -for klass in resources: - doc = klass.__doc__.replace("\n ", "\n") +def get_current_commit(): + p = subprocess.Popen( + "git rev-parse --verify HEAD", + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + stdout, stderr = p.communicate() + current_commit = stdout.strip().decode("utf-8") + return current_commit + + +current_commit = get_current_commit() + + +print( + f"""--- +title: App resources +template: docs +taxonomy: + category: docs +routes: + default: '/packaging_apps_resources' +--- + +Doc auto-generated by [this script](https://github.com/YunoHost/yunohost/blob/{current_commit}/doc/generate_resource_doc.py) on {today} (YunoHost version {version}) + +""" +) + + +fname = "../src/utils/resources.py" +content = open(fname).read() + +# NB: This magic is because we want to be able to run this script outside of a YunoHost context, +# in which we cant really 'import' the file because it will trigger a bunch of moulinette/yunohost imports... +tree = ast.parse(content) + +ResourceClasses = [ + c + for c in tree.body + if isinstance(c, ast.ClassDef) and c.bases and c.bases[0].id == "AppResource" +] + +ResourceDocString = {} + +for c in ResourceClasses: + assert c.body[1].targets[0].id == "type" + resource_id = c.body[1].value.value + docstring = ast.get_docstring(c) + + ResourceDocString[resource_id] = docstring + + +for resource_id, doc in sorted(ResourceDocString.items()): + doc = doc.replace("\n ", "\n") + + print("----------------") print("") - print(f"## {klass.type.replace('_', ' ').title()}") + print(f"## {resource_id.replace('_', ' ').title()}") print("") print(doc) + print("") diff --git a/helpers/apt b/helpers/apt index 8caf9f3dc..a2f2d3de8 100644 --- a/helpers/apt +++ b/helpers/apt @@ -277,7 +277,10 @@ ynh_install_app_dependencies() { ynh_app_setting_set --app=$app --key=phpversion --value=$specific_php_version # Set the default php version back as the default version for php-cli. - update-alternatives --set php /usr/bin/php$YNH_DEFAULT_PHP_VERSION + if test -e /usr/bin/php$YNH_DEFAULT_PHP_VERSION + then + update-alternatives --set php /usr/bin/php$YNH_DEFAULT_PHP_VERSION + fi elif grep --quiet 'php' <<< "$dependencies"; then ynh_app_setting_set --app=$app --key=phpversion --value=$YNH_DEFAULT_PHP_VERSION fi @@ -367,7 +370,13 @@ ynh_remove_app_dependencies() { apt-mark unhold ${dep_app}-ynh-deps fi - ynh_package_autopurge ${dep_app}-ynh-deps # Remove the fake package and its dependencies if they not still used. + # Remove the fake package and its dependencies if they not still used. + # (except if dpkg doesn't know anything about the package, + # which should be symptomatic of a failed install, and we don't want bash to report an error) + if dpkg-query --show ${dep_app}-ynh-deps &>/dev/null + then + ynh_package_autopurge ${dep_app}-ynh-deps + fi } # Install packages from an extra repository properly. diff --git a/helpers/backup b/helpers/backup index 22737ff86..ade3ce5e5 100644 --- a/helpers/backup +++ b/helpers/backup @@ -327,6 +327,13 @@ ynh_store_file_checksum() { ynh_app_setting_set --app=$app --key=$checksum_setting_name --value=$(md5sum "$file" | cut --delimiter=' ' --fields=1) + if [ ${PACKAGE_CHECK_EXEC:-0} -eq 1 ]; then + # Using a base64 is in fact more reversible than "replace / and space by _" ... So we can in fact obtain the original file path in an easy reliable way ... + local file_path_base64=$(echo "$file" | base64 -w0) + mkdir -p /var/cache/yunohost/appconfbackup/ + cat $file > /var/cache/yunohost/appconfbackup/original_${file_path_base64} + fi + # If backup_file_checksum isn't empty, ynh_backup_if_checksum_is_different has made a backup if [ -n "${backup_file_checksum-}" ]; then # Print the diff between the previous file and the new one. @@ -361,11 +368,20 @@ ynh_backup_if_checksum_is_different() { backup_file_checksum="" if [ -n "$checksum_value" ]; then # Proceed only if a value was stored into the app settings if [ -e $file ] && ! echo "$checksum_value $file" | md5sum --check --status; then # If the checksum is now different + backup_file_checksum="/var/cache/yunohost/appconfbackup/$file.backup.$(date '+%Y%m%d.%H%M%S')" mkdir --parents "$(dirname "$backup_file_checksum")" cp --archive "$file" "$backup_file_checksum" # Backup the current file ynh_print_warn "File $file has been manually modified since the installation or last upgrade. So it has been duplicated in $backup_file_checksum" echo "$backup_file_checksum" # Return the name of the backup file + if [ ${PACKAGE_CHECK_EXEC:-0} -eq 1 ]; then + local file_path_base64=$(echo "$file" | base64 -w0) + if test -e /var/cache/yunohost/appconfbackup/original_${file_path_base64} + then + ynh_print_warn "Diff with the original file:" + diff --report-identical-files --unified --color=always /var/cache/yunohost/appconfbackup/original_${file_path_base64} $file >&2 || true + fi + fi fi fi } diff --git a/helpers/logging b/helpers/logging index 4601e0b39..ab5d564aa 100644 --- a/helpers/logging +++ b/helpers/logging @@ -308,8 +308,8 @@ ynh_script_progression() { local progression_bar="${progress_string2:0:$effective_progression}${progress_string1:0:$expected_progression}${progress_string0:0:$left_progression}" local print_exec_time="" - if [ $time -eq 1 ]; then - print_exec_time=" [$(date +%Hh%Mm,%Ss --date="0 + $exec_time sec")]" + if [ $time -eq 1 ] && [ "$exec_time" -gt 10 ]; then + print_exec_time=" [$(bc <<< "scale=1; $exec_time / 60" ) minutes]" fi ynh_print_info "[$progression_bar] > ${message}${print_exec_time}" diff --git a/helpers/mysql b/helpers/mysql index 822159f27..a5290f794 100644 --- a/helpers/mysql +++ b/helpers/mysql @@ -133,7 +133,7 @@ ynh_mysql_dump_db() { # Manage arguments with getopts ynh_handle_getopts_args "$@" - mysqldump --single-transaction --skip-dump-date "$database" + mysqldump --single-transaction --skip-dump-date --routines "$database" } # Create a user diff --git a/helpers/nginx b/helpers/nginx index 9512f8d23..bb0fe0577 100644 --- a/helpers/nginx +++ b/helpers/nginx @@ -42,3 +42,37 @@ ynh_remove_nginx_config() { ynh_secure_remove --file="/etc/nginx/conf.d/$domain.d/$app.conf" ynh_systemd_action --service_name=nginx --action=reload } + + +# Move / regen the nginx config in a change url context +# +# usage: ynh_change_url_nginx_config +# +# Requires YunoHost version 11.1.9 or higher. +ynh_change_url_nginx_config() { + local old_nginx_conf_path=/etc/nginx/conf.d/$old_domain.d/$app.conf + local new_nginx_conf_path=/etc/nginx/conf.d/$new_domain.d/$app.conf + + # Change the path in the NGINX config file + if [ $change_path -eq 1 ] + then + # Make a backup of the original NGINX config file if modified + ynh_backup_if_checksum_is_different --file="$old_nginx_conf_path" + # Set global variables for NGINX helper + domain="$old_domain" + path="$new_path" + path_url="$new_path" + # Create a dedicated NGINX config + ynh_add_nginx_config + fi + + # Change the domain for NGINX + if [ $change_domain -eq 1 ] + then + ynh_delete_file_checksum --file="$old_nginx_conf_path" + mv "$old_nginx_conf_path" "$new_nginx_conf_path" + ynh_store_file_checksum --file="$new_nginx_conf_path" + fi + ynh_systemd_action --service_name=nginx --action=reload +} + diff --git a/helpers/nodejs b/helpers/nodejs index b692bfc70..e3ccf82dd 100644 --- a/helpers/nodejs +++ b/helpers/nodejs @@ -1,32 +1,10 @@ #!/bin/bash -n_version=9.0.1 -n_checksum=ad305e8ee9111aa5b08e6dbde23f01109401ad2d25deecacd880b3f9ea45702b n_install_dir="/opt/node_n" node_version_path="$n_install_dir/n/versions/node" # N_PREFIX is the directory of n, it needs to be loaded as a environment variable. export N_PREFIX="$n_install_dir" -# Install Node version management -# -# [internal] -# -# usage: ynh_install_n -# -# Requires YunoHost version 2.7.12 or higher. -ynh_install_n() { - # Build an app.src for n - echo "SOURCE_URL=https://github.com/tj/n/archive/v${n_version}.tar.gz -SOURCE_SUM=${n_checksum}" >"$YNH_APP_BASEDIR/conf/n.src" - # Download and extract n - ynh_setup_source --dest_dir="$n_install_dir/git" --source_id=n - # Install n - ( - cd "$n_install_dir/git" - PREFIX=$N_PREFIX make install 2>&1 - ) -} - # Load the version of node for an app, and set variables. # # usage: ynh_use_nodejs @@ -133,14 +111,10 @@ ynh_install_nodejs() { test -x /usr/bin/node && mv /usr/bin/node /usr/bin/node_n test -x /usr/bin/npm && mv /usr/bin/npm /usr/bin/npm_n - # If n is not previously setup, install it - if ! $n_install_dir/bin/n --version >/dev/null 2>&1; then - ynh_install_n - elif dpkg --compare-versions "$($n_install_dir/bin/n --version)" lt $n_version; then - ynh_install_n - fi - - # Modify the default N_PREFIX in n script + # Install (or update if YunoHost vendor/ folder updated since last install) n + mkdir -p $n_install_dir/bin/ + cp /usr/share/yunohost/helpers.d/vendor/n/n $n_install_dir/bin/n + # Tweak for n to understand it's installed in $N_PREFIX ynh_replace_string --match_string="^N_PREFIX=\${N_PREFIX-.*}$" --replace_string="N_PREFIX=\${N_PREFIX-$N_PREFIX}" --target_file="$n_install_dir/bin/n" # Restore /usr/local/bin in PATH diff --git a/helpers/php b/helpers/php index 6119c4870..417dbbc61 100644 --- a/helpers/php +++ b/helpers/php @@ -57,6 +57,7 @@ YNH_PHP_VERSION=${YNH_PHP_VERSION:-$YNH_DEFAULT_PHP_VERSION} # # Requires YunoHost version 4.1.0 or higher. ynh_add_fpm_config() { + local _globalphpversion=${phpversion-:} # Declare an array to define the options of this helper. local legacy_args=vtufpd local -A args_array=([v]=phpversion= [t]=use_template [u]=usage= [f]=footprint= [p]=package= [d]=dedicated_service) @@ -81,11 +82,16 @@ ynh_add_fpm_config() { dedicated_service=${dedicated_service:-0} # Set the default PHP-FPM version by default - phpversion="${phpversion:-$YNH_PHP_VERSION}" + if dpkg --compare-versions ${YNH_APP_PACKAGING_FORMAT:-0} lt 2; then + phpversion="${phpversion:-$YNH_PHP_VERSION}" + else + phpversion="${phpversion:-$_globalphpversion}" + fi local old_phpversion=$(ynh_app_setting_get --app=$app --key=phpversion) # If the PHP version changed, remove the old fpm conf + # (NB: This stuff is also handled by the apt helper, which is usually triggered before this helper) if [ -n "$old_phpversion" ] && [ "$old_phpversion" != "$phpversion" ]; then local old_php_fpm_config_dir=$(ynh_app_setting_get --app=$app --key=fpm_config_dir) local old_php_finalphpconf="$old_php_fpm_config_dir/pool.d/$app.conf" @@ -100,6 +106,7 @@ ynh_add_fpm_config() { # Legacy args (packager should just list their php dependency as regular apt dependencies... if [ -n "$package" ]; then # Install the additionnal packages from the default repository + ynh_print_warn --message "Argument --package of ynh_add_fpm_config is deprecated and to be removed in the future" ynh_install_app_dependencies "$package" fi @@ -156,7 +163,7 @@ ynh_add_fpm_config() { user = __APP__ group = __APP__ -chdir = __FINALPATH__ +chdir = __INSTALL_DIR__ listen = /var/run/php/php__PHPVERSION__-fpm-__APP__.sock listen.owner = www-data @@ -283,7 +290,7 @@ ynh_remove_fpm_config() { # If the PHP version used is not the default version for YunoHost # The second part with YNH_APP_PURGE is an ugly hack to guess that we're inside the remove script # (we don't actually care about its value, we just check its not empty hence it exists) - if [ "$phpversion" != "$YNH_DEFAULT_PHP_VERSION" ] && [ -n "${YNH_APP_PURGE:-}" ]; then + if [ "$phpversion" != "$YNH_DEFAULT_PHP_VERSION" ] && [ -n "${YNH_APP_PURGE:-}" ] && dpkg --compare-versions ${YNH_APP_PACKAGING_FORMAT:-0} lt 2; then # Remove app dependencies ... but ideally should happen via an explicit call from packager ynh_remove_app_dependencies fi @@ -481,6 +488,7 @@ YNH_COMPOSER_VERSION=${YNH_COMPOSER_VERSION:-$YNH_DEFAULT_COMPOSER_VERSION} # # Requires YunoHost version 4.2 or higher. ynh_composer_exec() { + local _globalphpversion=${phpversion-:} # Declare an array to define the options of this helper. local legacy_args=vwc declare -Ar args_array=([v]=phpversion= [w]=workdir= [c]=commands=) @@ -490,7 +498,12 @@ ynh_composer_exec() { # Manage arguments with getopts ynh_handle_getopts_args "$@" workdir="${workdir:-${install_dir:-$final_path}}" - phpversion="${phpversion:-$YNH_PHP_VERSION}" + + if dpkg --compare-versions ${YNH_APP_PACKAGING_FORMAT:-0} lt 2; then + phpversion="${phpversion:-$YNH_PHP_VERSION}" + else + phpversion="${phpversion:-$_globalphpversion}" + fi COMPOSER_HOME="$workdir/.composer" COMPOSER_MEMORY_LIMIT=-1 \ php${phpversion} "$workdir/composer.phar" $commands \ @@ -499,14 +512,15 @@ ynh_composer_exec() { # Install and initialize Composer in the given directory # -# usage: ynh_install_composer [--phpversion=phpversion] [--workdir=$final_path] [--install_args="--optimize-autoloader"] [--composerversion=composerversion] +# usage: ynh_install_composer [--phpversion=phpversion] [--workdir=$install_dir] [--install_args="--optimize-autoloader"] [--composerversion=composerversion] # | arg: -v, --phpversion - PHP version to use with composer -# | arg: -w, --workdir - The directory from where the command will be executed. Default $final_path. +# | arg: -w, --workdir - The directory from where the command will be executed. Default $install_dir. # | arg: -a, --install_args - Additional arguments provided to the composer install. Argument --no-dev already include # | arg: -c, --composerversion - Composer version to install # # Requires YunoHost version 4.2 or higher. ynh_install_composer() { + local _globalphpversion=${phpversion-:} # Declare an array to define the options of this helper. local legacy_args=vwac declare -Ar args_array=([v]=phpversion= [w]=workdir= [a]=install_args= [c]=composerversion=) @@ -516,8 +530,18 @@ ynh_install_composer() { local composerversion # Manage arguments with getopts ynh_handle_getopts_args "$@" - workdir="${workdir:-$final_path}" - phpversion="${phpversion:-$YNH_PHP_VERSION}" + if dpkg --compare-versions ${YNH_APP_PACKAGING_FORMAT:-0} lt 2; then + workdir="${workdir:-$final_path}" + else + workdir="${workdir:-$install_dir}" + fi + + if dpkg --compare-versions ${YNH_APP_PACKAGING_FORMAT:-0} lt 2; then + phpversion="${phpversion:-$YNH_PHP_VERSION}" + else + phpversion="${phpversion:-$_globalphpversion}" + fi + install_args="${install_args:-}" composerversion="${composerversion:-$YNH_COMPOSER_VERSION}" diff --git a/helpers/string b/helpers/string index 4dd5c0b4b..dc1658e3d 100644 --- a/helpers/string +++ b/helpers/string @@ -48,7 +48,7 @@ ynh_replace_string() { ynh_handle_getopts_args "$@" set +o xtrace # set +x - local delimit=@ + local delimit=$'\001' # Escape the delimiter if it's in the string. match_string=${match_string//${delimit}/"\\${delimit}"} replace_string=${replace_string//${delimit}/"\\${delimit}"} diff --git a/helpers/systemd b/helpers/systemd index 06551d2b3..761e818ad 100644 --- a/helpers/systemd +++ b/helpers/systemd @@ -61,7 +61,7 @@ ynh_remove_systemd_config() { # | arg: -l, --line_match= - Line to match - The line to find in the log to attest the service have finished to boot. If not defined it don't wait until the service is completely started. # | arg: -p, --log_path= - Log file - Path to the log file. Default : `/var/log/$app/$app.log` # | arg: -t, --timeout= - Timeout - The maximum time to wait before ending the watching. Default : 300 seconds. -# | arg: -e, --length= - Length of the error log : Default : 20 +# | arg: -e, --length= - Length of the error log displayed for debugging : Default : 20 # # Requires YunoHost version 3.5.0 or higher. ynh_systemd_action() { @@ -110,6 +110,8 @@ ynh_systemd_action() { action="reload-or-restart" fi + local time_start="$(date --utc --rfc-3339=seconds | cut -d+ -f1) UTC" + # If the service fails to perform the action if ! systemctl $action $service_name; then # Show syslog for this service @@ -128,9 +130,17 @@ ynh_systemd_action() { local i=0 for i in $(seq 1 $timeout); do # Read the log until the sentence is found, that means the app finished to start. Or run until the timeout - if grep --extended-regexp --quiet "$line_match" "$templog"; then - ynh_print_info --message="The service $service_name has correctly executed the action ${action}." - break + if [ "$log_path" == "systemd" ]; then + # For systemd services, we in fact dont rely on the templog, which for some reason is not reliable, but instead re-read journalctl every iteration, starting at the timestamp where we triggered the action + if journalctl --unit=$service_name --since="$time_start" --quiet --no-pager --no-hostname | grep --extended-regexp --quiet "$line_match"; then + ynh_print_info --message="The service $service_name has correctly executed the action ${action}." + break + fi + else + if grep --extended-regexp --quiet "$line_match" "$templog"; then + ynh_print_info --message="The service $service_name has correctly executed the action ${action}." + break + fi fi if [ $i -eq 30 ]; then echo "(this may take some time)" >&2 diff --git a/helpers/utils b/helpers/utils index 344493ff3..a88be38a8 100644 --- a/helpers/utils +++ b/helpers/utils @@ -22,7 +22,10 @@ YNH_APP_BASEDIR=${YNH_APP_BASEDIR:-$(realpath ..)} ynh_exit_properly() { local exit_code=$? - rm -rf "/var/cache/yunohost/download/" + if [[ "${YNH_APP_ACTION:-}" =~ ^install$|^upgrade$|^restore$ ]] + then + rm -rf "/var/cache/yunohost/download/" + fi if [ "$exit_code" -eq 0 ]; then exit 0 # Exit without error if the script ended correctly @@ -71,39 +74,79 @@ fi # # usage: ynh_setup_source --dest_dir=dest_dir [--source_id=source_id] [--keep="file1 file2"] [--full_replace] # | arg: -d, --dest_dir= - Directory where to setup sources -# | arg: -s, --source_id= - Name of the source, defaults to `app` +# | arg: -s, --source_id= - Name of the source, defaults to `main` (when the sources resource exists in manifest.toml) or (legacy) `app` otherwise # | arg: -k, --keep= - Space-separated list of files/folders that will be backup/restored in $dest_dir, such as a config file you don't want to overwrite. For example 'conf.json secrets.json logs/' # | arg: -r, --full_replace= - Remove previous sources before installing new sources # +# #### New 'sources' resources +# +# (See also the resources documentation which may be more complete?) +# +# This helper will read infos from the 'sources' resources in the manifest.toml of the app +# and expect a structure like: +# +# ```toml +# [resources.sources] +# [resources.sources.main] +# url = "https://some.address.to/download/the/app/archive" +# sha256 = "0123456789abcdef" # The sha256 sum of the asset obtained from the URL +# ``` +# +# ##### Optional flags +# +# ```text +# format = "tar.gz"/xz/bz2 # automatically guessed from the extension of the URL, but can be set explicitly. Will use `tar` to extract +# "zip" # automatically guessed from the extension of the URL, but can be set explicitly. Will use `unzip` to extract +# "docker" # useful to extract files from an already-built docker image (instead of rebuilding them locally). Will use `docker-image-extract` to extract +# "whatever" # an arbitrary value, not really meaningful except to imply that the file won't be extracted +# +# in_subdir = true # default, there's an intermediate subdir in the archive before accessing the actual files +# false # sources are directly in the archive root +# n # (special cases) an integer representing a number of subdirs levels to get rid of +# +# extract = true # default if file is indeed an archive such as .zip, .tar.gz, .tar.bz2, ... +# = false # default if file 'format' is not set and the file is not to be extracted because it is not an archive but a script or binary or whatever asset. +# # in which case the file will only be `mv`ed to the location possibly renamed using the `rename` value +# +# rename = "whatever_your_want" # to be used for convenience when `extract` is false and the default name of the file is not practical +# platform = "linux/amd64" # (defaults to "linux/$YNH_ARCH") to be used in conjonction with `format = "docker"` to specify which architecture to extract for +# ``` +# +# You may also define assets url and checksum per-architectures such as: +# ```toml +# [resources.sources] +# [resources.sources.main] +# amd64.url = "https://some.address.to/download/the/app/archive/when/amd64" +# amd64.sha256 = "0123456789abcdef" +# armhf.url = "https://some.address.to/download/the/app/archive/when/armhf" +# armhf.sha256 = "fedcba9876543210" +# ``` +# +# In which case ynh_setup_source --dest_dir="$install_dir" will automatically pick the appropriate source depending on the arch +# +# +# +# #### Legacy format '.src' +# # This helper will read `conf/${source_id}.src`, download and install the sources. # # The src file need to contains: # ``` # SOURCE_URL=Address to download the app archive -# SOURCE_SUM=Control sum -# # (Optional) Program to check the integrity (sha256sum, md5sum...). Default: sha256 -# SOURCE_SUM_PRG=sha256 -# # (Optional) Archive format. Default: tar.gz +# SOURCE_SUM=Sha256 sum # SOURCE_FORMAT=tar.gz -# # (Optional) Put false if sources are directly in the archive root. Default: true -# # Instead of true, SOURCE_IN_SUBDIR could be the number of sub directories to remove. # SOURCE_IN_SUBDIR=false -# # (Optionnal) Name of the local archive (offline setup support). Default: ${src_id}.${src_format} # SOURCE_FILENAME=example.tar.gz -# # (Optional) If it set as false don't extract the source. Default: true -# # (Useful to get a debian package or a python wheel.) # SOURCE_EXTRACT=(true|false) -# # (Optionnal) Name of the plateform. Default: "linux/$YNH_ARCH" # SOURCE_PLATFORM=linux/arm64/v8 # ``` # # The helper will: -# - Check if there is a local source archive in `/opt/yunohost-apps-src/$APP_ID/$SOURCE_FILENAME` -# - Download `$SOURCE_URL` if there is no local archive -# - Check the integrity with `$SOURCE_SUM_PRG -c --status` +# - Download the specific URL if there is no local archive +# - Check the integrity with the specific sha256 sum # - Uncompress the archive to `$dest_dir`. -# - If `$SOURCE_IN_SUBDIR` is true, the first level directory of the archive will be removed. -# - If `$SOURCE_IN_SUBDIR` is a numeric value, the N first level directories will be removed. +# - If `in_subdir` is true, the first level directory of the archive will be removed. +# - If `in_subdir` is a numeric value, the N first level directories will be removed. # - Patches named `sources/patches/${src_id}-*.patch` will be applied to `$dest_dir` # - Extra files in `sources/extra_files/$src_id` will be copied to dest_dir # @@ -118,22 +161,66 @@ ynh_setup_source() { local full_replace # Manage arguments with getopts ynh_handle_getopts_args "$@" - source_id="${source_id:-app}" keep="${keep:-}" full_replace="${full_replace:-0}" - local src_file_path="$YNH_APP_BASEDIR/conf/${source_id}.src" + if test -e $YNH_APP_BASEDIR/manifest.toml && cat $YNH_APP_BASEDIR/manifest.toml | toml_to_json | jq -e '.resources.sources' >/dev/null + then + source_id="${source_id:-main}" + local sources_json=$(cat $YNH_APP_BASEDIR/manifest.toml | toml_to_json | jq ".resources.sources[\"$source_id\"]") + if jq -re ".url" <<< "$sources_json" + then + local arch_prefix="" + else + local arch_prefix=".$YNH_ARCH" + fi - # Load value from configuration file (see above for a small doc about this file - # format) - local src_url=$(grep 'SOURCE_URL=' "$src_file_path" | cut --delimiter='=' --fields=2-) - local src_sum=$(grep 'SOURCE_SUM=' "$src_file_path" | cut --delimiter='=' --fields=2-) - local src_sumprg=$(grep 'SOURCE_SUM_PRG=' "$src_file_path" | cut --delimiter='=' --fields=2-) - local src_format=$(grep 'SOURCE_FORMAT=' "$src_file_path" | cut --delimiter='=' --fields=2-) - local src_in_subdir=$(grep 'SOURCE_IN_SUBDIR=' "$src_file_path" | cut --delimiter='=' --fields=2-) - local src_filename=$(grep 'SOURCE_FILENAME=' "$src_file_path" | cut --delimiter='=' --fields=2-) - local src_extract=$(grep 'SOURCE_EXTRACT=' "$src_file_path" | cut --delimiter='=' --fields=2-) - local src_plateform=$(grep 'SOURCE_PLATFORM=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_url="$(jq -r "$arch_prefix.url" <<< "$sources_json" | sed 's/^null$//')" + local src_sum="$(jq -r "$arch_prefix.sha256" <<< "$sources_json" | sed 's/^null$//')" + local src_sumprg="sha256sum" + local src_format="$(jq -r ".format" <<< "$sources_json" | sed 's/^null$//')" + local src_in_subdir="$(jq -r ".in_subdir" <<< "$sources_json" | sed 's/^null$//')" + local src_extract="$(jq -r ".extract" <<< "$sources_json" | sed 's/^null$//')" + local src_platform="$(jq -r ".platform" <<< "$sources_json" | sed 's/^null$//')" + local src_rename="$(jq -r ".rename" <<< "$sources_json" | sed 's/^null$//')" + + [[ -n "$src_url" ]] || ynh_die "No URL defined for source $source_id$arch_prefix ?" + [[ -n "$src_sum" ]] || ynh_die "No sha256 sum defined for source $source_id$arch_prefix ?" + + if [[ -z "$src_format" ]] + then + if [[ "$src_url" =~ ^.*\.zip$ ]] || [[ "$src_url" =~ ^.*/zipball/.*$ ]] + then + src_format="zip" + elif [[ "$src_url" =~ ^.*\.tar\.gz$ ]] || [[ "$src_url" =~ ^.*\.tgz$ ]] || [[ "$src_url" =~ ^.*/tar\.gz/.*$ ]] || [[ "$src_url" =~ ^.*/tarball/.*$ ]] + then + src_format="tar.gz" + elif [[ "$src_url" =~ ^.*\.tar\.xz$ ]] + then + src_format="tar.xz" + elif [[ "$src_url" =~ ^.*\.tar\.bz2$ ]] + then + src_format="tar.bz2" + elif [[ -z "$src_extract" ]] + then + src_extract="false" + fi + fi + else + source_id="${source_id:-app}" + local src_file_path="$YNH_APP_BASEDIR/conf/${source_id}.src" + + # Load value from configuration file (see above for a small doc about this file + # format) + local src_url=$(grep 'SOURCE_URL=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_sum=$(grep 'SOURCE_SUM=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_sumprg=$(grep 'SOURCE_SUM_PRG=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_format=$(grep 'SOURCE_FORMAT=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_in_subdir=$(grep 'SOURCE_IN_SUBDIR=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_rename=$(grep 'SOURCE_FILENAME=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_extract=$(grep 'SOURCE_EXTRACT=' "$src_file_path" | cut --delimiter='=' --fields=2-) + local src_platform=$(grep 'SOURCE_PLATFORM=' "$src_file_path" | cut --delimiter='=' --fields=2-) + fi # Default value src_sumprg=${src_sumprg:-sha256sum} @@ -141,33 +228,53 @@ ynh_setup_source() { src_format=${src_format:-tar.gz} src_format=$(echo "$src_format" | tr '[:upper:]' '[:lower:]') src_extract=${src_extract:-true} - if [ "$src_filename" = "" ]; then - src_filename="${source_id}.${src_format}" + + if [[ "$src_extract" != "true" ]] && [[ "$src_extract" != "false" ]] + then + ynh_die "For source $source_id, expected either 'true' or 'false' for the extract parameter" fi - # (Unused?) mecanism where one can have the file in a special local cache to not have to download it... - local local_src="/opt/yunohost-apps-src/${YNH_APP_ID}/${src_filename}" - mkdir -p /var/cache/yunohost/download/${YNH_APP_ID}/ - src_filename="/var/cache/yunohost/download/${YNH_APP_ID}/${src_filename}" + # (Unused?) mecanism where one can have the file in a special local cache to not have to download it... + local local_src="/opt/yunohost-apps-src/${YNH_APP_ID}/${source_id}" + + # Gotta use this trick with 'dirname' because source_id may contain slashes x_x + mkdir -p $(dirname /var/cache/yunohost/download/${YNH_APP_INSTANCE_NAME}/${source_id}) + src_filename="/var/cache/yunohost/download/${YNH_APP_INSTANCE_NAME}/${source_id}" if [ "$src_format" = "docker" ]; then - src_plateform="${src_plateform:-"linux/$YNH_ARCH"}" + src_platform="${src_platform:-"linux/$YNH_ARCH"}" elif test -e "$local_src"; then cp $local_src $src_filename else [ -n "$src_url" ] || ynh_die "Couldn't parse SOURCE_URL from $src_file_path ?" - # NB. we have to declare the var as local first, - # otherwise 'local foo=$(false) || echo 'pwet'" does'nt work - # because local always return 0 ... - local out - # Timeout option is here to enforce the timeout on dns query and tcp connect (c.f. man wget) - out=$(wget --tries 3 --no-dns-cache --timeout 900 --no-verbose --output-document=$src_filename $src_url 2>&1) \ - || ynh_die --message="$out" + # If the file was prefetched but somehow doesn't match the sum, rm and redownload it + if [ -e "$src_filename" ] && ! echo "${src_sum} ${src_filename}" | ${src_sumprg} --check --status + then + rm -f "$src_filename" + fi + + # Only redownload the file if it wasnt prefetched + if [ ! -e "$src_filename" ] + then + # NB. we have to declare the var as local first, + # otherwise 'local foo=$(false) || echo 'pwet'" does'nt work + # because local always return 0 ... + local out + # Timeout option is here to enforce the timeout on dns query and tcp connect (c.f. man wget) + out=$(wget --tries 3 --no-dns-cache --timeout 900 --no-verbose --output-document=$src_filename $src_url 2>&1) \ + || ynh_die --message="$out" + fi + # Check the control sum - echo "${src_sum} ${src_filename}" | ${src_sumprg} --check --status \ - || ynh_die --message="Corrupt source" + if ! echo "${src_sum} ${src_filename}" | ${src_sumprg} --check --status + then + local actual_sum="$(${src_sumprg} ${src_filename} | cut --delimiter=' ' --fields=1)" + local actual_size="$(du -hs ${src_filename} | cut --delimiter=' ' --fields=1)" + rm -f ${src_filename} + ynh_die --message="Corrupt source for ${src_filename}: Expected ${src_sum} but got ${actual_sum} (size: ${actual_size})." + fi fi # Keep files to be backup/restored at the end of the helper @@ -199,11 +306,16 @@ ynh_setup_source() { _ynh_apply_default_permissions $dest_dir fi - if ! "$src_extract"; then - mv $src_filename $dest_dir - elif [ "$src_format" = "docker" ]; then - /usr/share/yunohost/helpers.d/vendor/docker-image-extract/docker-image-extract -p $src_plateform -o $dest_dir $src_url 2>&1 - elif [ "$src_format" = "zip" ]; then + if [[ "$src_extract" == "false" ]]; then + if [[ -z "$src_rename" ]] + then + mv $src_filename $dest_dir + else + mv $src_filename $dest_dir/$src_rename + fi + elif [[ "$src_format" == "docker" ]]; then + /usr/share/yunohost/helpers.d/vendor/docker-image-extract/docker-image-extract -p $src_platform -o $dest_dir $src_url 2>&1 + elif [[ "$src_format" == "zip" ]]; then # Zip format # Using of a temp directory, because unzip doesn't manage --strip-components if $src_in_subdir; then @@ -348,7 +460,7 @@ ynh_local_curl() { # __NAMETOCHANGE__ by $app # __USER__ by $app # __FINALPATH__ by $final_path -# __PHPVERSION__ by $YNH_PHP_VERSION +# __PHPVERSION__ by $YNH_PHP_VERSION (packaging v1 only, packaging v2 uses phpversion setting implicitly set by apt resource) # __YNH_NODE_LOAD_PATH__ by $ynh_node_load_PATH # ``` # And any dynamic variables that should be defined before calling this helper like: @@ -417,7 +529,7 @@ ynh_add_config() { # __NAMETOCHANGE__ by $app # __USER__ by $app # __FINALPATH__ by $final_path -# __PHPVERSION__ by $YNH_PHP_VERSION +# __PHPVERSION__ by $YNH_PHP_VERSION (packaging v1 only, packaging v2 uses phpversion setting implicitly set by apt resource) # __YNH_NODE_LOAD_PATH__ by $ynh_node_load_PATH # # And any dynamic variables that should be defined before calling this helper like: @@ -452,7 +564,8 @@ ynh_replace_vars() { ynh_replace_string --match_string="__FINALPATH__" --replace_string="$final_path" --target_file="$file" ynh_replace_string --match_string="__INSTALL_DIR__" --replace_string="$final_path" --target_file="$file" fi - if test -n "${YNH_PHP_VERSION:-}"; then + # Legacy / Packaging v1 only + if dpkg --compare-versions ${YNH_APP_PACKAGING_FORMAT:-0} lt 2 && test -n "${YNH_PHP_VERSION:-}"; then ynh_replace_string --match_string="__PHPVERSION__" --replace_string="$YNH_PHP_VERSION" --target_file="$file" fi if test -n "${ynh_node_load_PATH:-}"; then @@ -567,7 +680,7 @@ ynh_read_var_in_file() { var_part+='\s*' # Extract the part after assignation sign - local expression_with_comment="$(tail +$line_number ${file} | grep -i -o -P $var_part'\K.*$' || echo YNH_NULL | head -n1)" + local expression_with_comment="$((tail +$line_number ${file} | grep -i -o -P $var_part'\K.*$' || echo YNH_NULL) | head -n1)" if [[ "$expression_with_comment" == "YNH_NULL" ]]; then set -o xtrace # set -x echo YNH_NULL @@ -613,15 +726,14 @@ ynh_write_var_in_file() { set +o xtrace # set +x # Get the line number after which we search for the variable - local line_number=1 + local after_line_number=1 if [[ -n "$after" ]]; then - line_number=$(grep -m1 -n $after $file | cut -d: -f1) - if [[ -z "$line_number" ]]; then + after_line_number=$(grep -m1 -n $after $file | cut -d: -f1) + if [[ -z "$after_line_number" ]]; then set -o xtrace # set -x return 1 fi fi - local range="${line_number},\$ " local filename="$(basename -- "$file")" local ext="${filename##*.}" @@ -646,17 +758,21 @@ ynh_write_var_in_file() { var_part+='\s*' # Extract the part after assignation sign - local expression_with_comment="$(tail +$line_number ${file} | grep -i -o -P $var_part'\K.*$' || echo YNH_NULL | head -n1)" + local expression_with_comment="$((tail +$after_line_number ${file} | grep -i -o -P $var_part'\K.*$' || echo YNH_NULL) | head -n1)" if [[ "$expression_with_comment" == "YNH_NULL" ]]; then set -o xtrace # set -x return 1 fi + local value_line_number="$(tail +$after_line_number ${file} | grep -m1 -n -i -P $var_part'\K.*$' | cut -d: -f1)" + value_line_number=$((after_line_number + value_line_number)) + local range="${after_line_number},${value_line_number} " # Remove comments if needed local expression="$(echo "$expression_with_comment" | sed "s@${comments}[^$string]*\$@@g" | sed "s@\s*[$endline]*\s*]*\$@@")" endline=${expression_with_comment#"$expression"} endline="$(echo "$endline" | sed 's/\\/\\\\/g')" value="$(echo "$value" | sed 's/\\/\\\\/g')" + value=${value//&/"\&"} local first_char="${expression:0:1}" delimiter=$'\001' if [[ "$first_char" == '"' ]]; then @@ -718,7 +834,7 @@ _acceptable_path_to_delete() { local forbidden_paths=$(ls -d / /* /{var,home,usr}/* /etc/{default,sudoers.d,yunohost,cron*}) # Legacy : A couple apps still have data in /home/$app ... - if [[ -n "$app" ]] + if [[ -n "${app:-}" ]] then forbidden_paths=$(echo "$forbidden_paths" | grep -v "/home/$app") fi @@ -966,3 +1082,7 @@ _ynh_apply_default_permissions() { int_to_bool() { sed -e 's/^1$/True/g' -e 's/^0$/False/g' } + +toml_to_json() { + python3 -c 'import toml, json, sys; print(json.dumps(toml.load(sys.stdin)))' +} diff --git a/helpers/vendor/n/LICENSE b/helpers/vendor/n/LICENSE new file mode 100644 index 000000000..8e04e8467 --- /dev/null +++ b/helpers/vendor/n/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 TJ Holowaychuk + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/helpers/vendor/n/README.md b/helpers/vendor/n/README.md new file mode 100644 index 000000000..9a29a3936 --- /dev/null +++ b/helpers/vendor/n/README.md @@ -0,0 +1 @@ +This is taken from https://github.com/tj/n/ diff --git a/helpers/vendor/n/n b/helpers/vendor/n/n new file mode 100755 index 000000000..2739e2d00 --- /dev/null +++ b/helpers/vendor/n/n @@ -0,0 +1,1621 @@ +#!/usr/bin/env bash +# shellcheck disable=SC2155 +# Disabled "Declare and assign separately to avoid masking return values": https://github.com/koalaman/shellcheck/wiki/SC2155 + +# +# log +# + +log() { + printf " ${SGR_CYAN}%10s${SGR_RESET} : ${SGR_FAINT}%s${SGR_RESET}\n" "$1" "$2" +} + +# +# verbose_log +# Can suppress with --quiet. +# Like log but to stderr rather than stdout, so can also be used from "display" routines. +# + +verbose_log() { + if [[ "${SHOW_VERBOSE_LOG}" == "true" ]]; then + >&2 printf " ${SGR_CYAN}%10s${SGR_RESET} : ${SGR_FAINT}%s${SGR_RESET}\n" "$1" "$2" + fi +} + +# +# Exit with the given +# + +abort() { + >&2 printf "\n ${SGR_RED}Error: %s${SGR_RESET}\n\n" "$*" && exit 1 +} + +# +# Synopsis: trace message ... +# Debugging output to stderr, not used in production code. +# + +function trace() { + >&2 printf "trace: %s\n" "$*" +} + +# +# Synopsis: echo_red message ... +# Highlight message in colour (on stdout). +# + +function echo_red() { + printf "${SGR_RED}%s${SGR_RESET}\n" "$*" +} + +# +# Synopsis: n_grep +# grep wrapper to ensure consistent grep options and circumvent aliases. +# + +function n_grep() { + GREP_OPTIONS='' command grep "$@" +} + +# +# Setup and state +# + +VERSION="v9.0.1" + +N_PREFIX="${N_PREFIX-/usr/local}" +N_PREFIX=${N_PREFIX%/} +readonly N_PREFIX + +N_CACHE_PREFIX="${N_CACHE_PREFIX-${N_PREFIX}}" +N_CACHE_PREFIX=${N_CACHE_PREFIX%/} +CACHE_DIR="${N_CACHE_PREFIX}/n/versions" +readonly N_CACHE_PREFIX CACHE_DIR + +N_NODE_MIRROR=${N_NODE_MIRROR:-${NODE_MIRROR:-https://nodejs.org/dist}} +N_NODE_MIRROR=${N_NODE_MIRROR%/} +readonly N_NODE_MIRROR + +N_NODE_DOWNLOAD_MIRROR=${N_NODE_DOWNLOAD_MIRROR:-https://nodejs.org/download} +N_NODE_DOWNLOAD_MIRROR=${N_NODE_DOWNLOAD_MIRROR%/} +readonly N_NODE_DOWNLOAD_MIRROR + +# Using xz instead of gzip is enabled by default, if xz compatibility checks pass. +# User may set N_USE_XZ to 0 to disable, or set to anything else to enable. +# May also be overridden by command line flags. + +# Normalise external values to true/false +if [[ "${N_USE_XZ}" = "0" ]]; then + N_USE_XZ="false" +elif [[ -n "${N_USE_XZ+defined}" ]]; then + N_USE_XZ="true" +fi +# Not setting to readonly. Overriden by CLI flags, and update_xz_settings_for_version. + +N_MAX_REMOTE_MATCHES=${N_MAX_REMOTE_MATCHES:-20} +# modified by update_mirror_settings_for_version +g_mirror_url=${N_NODE_MIRROR} +g_mirror_folder_name="node" + +# Options for curl and wget. +# Defining commands in variables is fraught (https://mywiki.wooledge.org/BashFAQ/050) +# but we can follow the simple case and store arguments in an array. + +GET_SHOWS_PROGRESS="false" +# --location to follow redirects +# --fail to avoid happily downloading error page from web server for 404 et al +# --show-error to show why failed (on stderr) +CURL_OPTIONS=( "--location" "--fail" "--show-error" ) +if [[ -t 1 ]]; then + CURL_OPTIONS+=( "--progress-bar" ) + command -v curl &> /dev/null && GET_SHOWS_PROGRESS="true" +else + CURL_OPTIONS+=( "--silent" ) +fi +WGET_OPTIONS=( "-q" "-O-" ) + +# Legacy support using unprefixed env. No longer documented in README. +if [ -n "$HTTP_USER" ];then + if [ -z "$HTTP_PASSWORD" ]; then + abort "Must specify HTTP_PASSWORD when supplying HTTP_USER" + fi + CURL_OPTIONS+=( "-u $HTTP_USER:$HTTP_PASSWORD" ) + WGET_OPTIONS+=( "--http-password=$HTTP_PASSWORD" + "--http-user=$HTTP_USER" ) +elif [ -n "$HTTP_PASSWORD" ]; then + abort "Must specify HTTP_USER when supplying HTTP_PASSWORD" +fi + +# Set by set_active_node +g_active_node= + +# set by various lookups to allow mixed logging and return value from function, especially for engine and node +g_target_node= + +DOWNLOAD=false # set to opt-out of activate (install), and opt-in to download (run, exec) +ARCH= +SHOW_VERBOSE_LOG="true" + +# ANSI escape codes +# https://en.wikipedia.org/wiki/ANSI_escape_code +# https://no-color.org +# https://bixense.com/clicolors + +USE_COLOR="true" +if [[ -n "${CLICOLOR_FORCE+defined}" && "${CLICOLOR_FORCE}" != "0" ]]; then + USE_COLOR="true" +elif [[ -n "${NO_COLOR+defined}" || "${CLICOLOR}" = "0" || ! -t 1 ]]; then + USE_COLOR="false" +fi +readonly USE_COLOR +# Select Graphic Rendition codes +if [[ "${USE_COLOR}" = "true" ]]; then + # KISS and use codes rather than tput, avoid dealing with missing tput or TERM. + readonly SGR_RESET="\033[0m" + readonly SGR_FAINT="\033[2m" + readonly SGR_RED="\033[31m" + readonly SGR_CYAN="\033[36m" +else + readonly SGR_RESET= + readonly SGR_FAINT= + readonly SGR_RED= + readonly SGR_CYAN= +fi + +# +# set_arch to override $(uname -a) +# + +set_arch() { + if test -n "$1"; then + ARCH="$1" + else + abort "missing -a|--arch value" + fi +} + +# +# Synopsis: set_insecure +# Globals modified: +# - CURL_OPTIONS +# - WGET_OPTIONS +# + +function set_insecure() { + CURL_OPTIONS+=( "--insecure" ) + WGET_OPTIONS+=( "--no-check-certificate" ) +} + +# +# Synposis: display_major_version numeric-version +# +display_major_version() { + local version=$1 + version="${version#v}" + version="${version%%.*}" + echo "${version}" +} + +# +# Synopsis: update_mirror_settings_for_version version +# e.g. means using download mirror and folder is nightly +# Globals modified: +# - g_mirror_url +# - g_mirror_folder_name +# + +function update_mirror_settings_for_version() { + if is_download_folder "$1" ; then + g_mirror_folder_name="$1" + g_mirror_url="${N_NODE_DOWNLOAD_MIRROR}/${g_mirror_folder_name}" + elif is_download_version "$1"; then + [[ "$1" =~ ^([^/]+)/(.*) ]] + local remote_folder="${BASH_REMATCH[1]}" + g_mirror_folder_name="${remote_folder}" + g_mirror_url="${N_NODE_DOWNLOAD_MIRROR}/${g_mirror_folder_name}" + fi +} + +# +# Synopsis: update_xz_settings_for_version numeric-version +# Globals modified: +# - N_USE_XZ +# + +function update_xz_settings_for_version() { + # tarballs in xz format were available in later version of iojs, but KISS and only use xz from v4. + if [[ "${N_USE_XZ}" = "true" ]]; then + local major_version="$(display_major_version "$1")" + if [[ "${major_version}" -lt 4 ]]; then + N_USE_XZ="false" + fi + fi +} + +# +# Synopsis: update_arch_settings_for_version numeric-version +# Globals modified: +# - ARCH +# + +function update_arch_settings_for_version() { + local tarball_platform="$(display_tarball_platform)" + if [[ -z "${ARCH}" && "${tarball_platform}" = "darwin-arm64" ]]; then + # First native builds were for v16, but can use x64 in rosetta for older versions. + local major_version="$(display_major_version "$1")" + if [[ "${major_version}" -lt 16 ]]; then + ARCH=x64 + fi + fi +} + +# +# Synopsis: is_lts_codename version +# + +function is_lts_codename() { + # https://github.com/nodejs/Release/blob/master/CODENAMES.md + # e.g. argon, Boron + [[ "$1" =~ ^([Aa]rgon|[Bb]oron|[Cc]arbon|[Dd]ubnium|[Ee]rbium|[Ff]ermium|[Gg]allium|[Hh]ydrogen|[Ii]ron|[Jj]od)$ ]] +} + +# +# Synopsis: is_download_folder version +# + +function is_download_folder() { + # e.g. nightly + [[ "$1" =~ ^(next-nightly|nightly|rc|release|test|v8-canary)$ ]] +} + +# +# Synopsis: is_download_version version +# + +function is_download_version() { + # e.g. nightly/, nightly/latest, nightly/v11 + if [[ "$1" =~ ^([^/]+)/(.*) ]]; then + local remote_folder="${BASH_REMATCH[1]}" + is_download_folder "${remote_folder}" + return + fi + return 2 +} + +# +# Synopsis: is_numeric_version version +# + +function is_numeric_version() { + # e.g. 6, v7.1, 8.11.3 + [[ "$1" =~ ^[v]{0,1}[0-9]+(\.[0-9]+){0,2}$ ]] +} + +# +# Synopsis: is_exact_numeric_version version +# + +function is_exact_numeric_version() { + # e.g. 6, v7.1, 8.11.3 + [[ "$1" =~ ^[v]{0,1}[0-9]+\.[0-9]+\.[0-9]+$ ]] +} + +# +# Synopsis: is_node_support_version version +# Reference: https://github.com/nodejs/package-maintenance/issues/236#issue-474783582 +# + +function is_node_support_version() { + [[ "$1" =~ ^(active|lts_active|lts_latest|lts|current|supported)$ ]] +} + +# +# Synopsis: display_latest_node_support_alias version +# Map aliases onto existing n aliases, current and lts +# + +function display_latest_node_support_alias() { + case "$1" in + "active") printf "current" ;; + "lts_active") printf "lts" ;; + "lts_latest") printf "lts" ;; + "lts") printf "lts" ;; + "current") printf "current" ;; + "supported") printf "current" ;; + *) printf "unexpected-version" + esac +} + +# +# Functions used when showing versions installed +# + +enter_fullscreen() { + # Set cursor to be invisible + tput civis 2> /dev/null + # Save screen contents + tput smcup 2> /dev/null + stty -echo +} + +leave_fullscreen() { + # Set cursor to normal + tput cnorm 2> /dev/null + # Restore screen contents + tput rmcup 2> /dev/null + stty echo +} + +handle_sigint() { + leave_fullscreen + S="$?" + kill 0 + exit $S +} + +handle_sigtstp() { + leave_fullscreen + kill -s SIGSTOP $$ +} + +# +# Output usage information. +# + +display_help() { + cat <<-EOF + +Usage: n [options] [COMMAND] [args] + +Commands: + + n Display downloaded Node.js versions and install selection + n latest Install the latest Node.js release (downloading if necessary) + n lts Install the latest LTS Node.js release (downloading if necessary) + n Install Node.js (downloading if necessary) + n install Install Node.js (downloading if necessary) + n run [args ...] Execute downloaded Node.js with [args ...] + n which Output path for downloaded node + n exec [args...] Execute command with modified PATH, so downloaded node and npm first + n rm Remove the given downloaded version(s) + n prune Remove all downloaded versions except the installed version + n --latest Output the latest Node.js version available + n --lts Output the latest LTS Node.js version available + n ls Output downloaded versions + n ls-remote [version] Output matching versions available for download + n uninstall Remove the installed Node.js + +Options: + + -V, --version Output version of n + -h, --help Display help information + -p, --preserve Preserve npm and npx during install of Node.js + -q, --quiet Disable curl output. Disable log messages processing "auto" and "engine" labels. + -d, --download Download if necessary, and don't make active + -a, --arch Override system architecture + --all ls-remote displays all matches instead of last 20 + --insecure Turn off certificate checking for https requests (may be needed from behind a proxy server) + --use-xz/--no-use-xz Override automatic detection of xz support and enable/disable use of xz compressed node downloads. + +Aliases: + + install: i + latest: current + ls: list + lsr: ls-remote + lts: stable + rm: - + run: use, as + which: bin + +Versions: + + Numeric version numbers can be complete or incomplete, with an optional leading 'v'. + Versions can also be specified by label, or codename, + and other downloadable releases by / + + 4.9.1, 8, v6.1 Numeric versions + lts Newest Long Term Support official release + latest, current Newest official release + auto Read version from file: .n-node-version, .node-version, .nvmrc, or package.json + engine Read version from package.json + boron, carbon Codenames for release streams + lts_latest Node.js support aliases + + and nightly, rc/10 et al + +EOF +} + +err_no_installed_print_help() { + display_help + abort "no downloaded versions yet, see above help for commands" +} + +# +# Synopsis: next_version_installed selected_version +# Output version after selected (which may be blank under some circumstances). +# + +function next_version_installed() { + display_cache_versions | n_grep "$1" -A 1 | tail -n 1 +} + +# +# Synopsis: prev_version_installed selected_version +# Output version before selected (which may be blank under some circumstances). +# + +function prev_version_installed() { + display_cache_versions | n_grep "$1" -B 1 | head -n 1 +} + +# +# Output n version. +# + +display_n_version() { + echo "$VERSION" && exit 0 +} + +# +# Synopsis: set_active_node +# Checks cached downloads for a binary matching the active node. +# Globals modified: +# - g_active_node +# + +function set_active_node() { + g_active_node= + local node_path="$(command -v node)" + if [[ -x "${node_path}" ]]; then + local installed_version=$(node --version) + installed_version=${installed_version#v} + for dir in "${CACHE_DIR}"/*/ ; do + local folder_name="${dir%/}" + folder_name="${folder_name##*/}" + if diff &> /dev/null \ + "${CACHE_DIR}/${folder_name}/${installed_version}/bin/node" \ + "${node_path}" ; then + g_active_node="${folder_name}/${installed_version}" + break + fi + done + fi +} + +# +# Display sorted versions directories paths. +# + +display_versions_paths() { + find "$CACHE_DIR" -maxdepth 2 -type d \ + | sed 's|'"$CACHE_DIR"'/||g' \ + | n_grep -E "/[0-9]+\.[0-9]+\.[0-9]+" \ + | sed 's|/|.|' \ + | sort -k 1,1 -k 2,2n -k 3,3n -k 4,4n -t . \ + | sed 's|\.|/|' +} + +# +# Display installed versions with +# + +display_versions_with_selected() { + local selected="$1" + echo + for version in $(display_versions_paths); do + if test "$version" = "$selected"; then + printf " ${SGR_CYAN}ο${SGR_RESET} %s\n" "$version" + else + printf " ${SGR_FAINT}%s${SGR_RESET}\n" "$version" + fi + done + echo + printf "Use up/down arrow keys to select a version, return key to install, d to delete, q to quit" +} + +# +# Synopsis: display_cache_versions +# + +function display_cache_versions() { + for folder_and_version in $(display_versions_paths); do + echo "${folder_and_version}" + done +} + +# +# Display current node --version and others installed. +# + +menu_select_cache_versions() { + enter_fullscreen + set_active_node + local selected="${g_active_node}" + + clear + display_versions_with_selected "${selected}" + + trap handle_sigint INT + trap handle_sigtstp SIGTSTP + + ESCAPE_SEQ=$'\033' + UP=$'A' + DOWN=$'B' + CTRL_P=$'\020' + CTRL_N=$'\016' + + while true; do + read -rsn 1 key + case "$key" in + "$ESCAPE_SEQ") + # Handle ESC sequences followed by other characters, i.e. arrow keys + read -rsn 1 -t 1 tmp + # See "[" if terminal in normal mode, and "0" in application mode + if [[ "$tmp" == "[" || "$tmp" == "O" ]]; then + read -rsn 1 -t 1 arrow + case "$arrow" in + "$UP") + clear + selected="$(prev_version_installed "${selected}")" + display_versions_with_selected "${selected}" + ;; + "$DOWN") + clear + selected="$(next_version_installed "${selected}")" + display_versions_with_selected "${selected}" + ;; + esac + fi + ;; + "d") + if [[ -n "${selected}" ]]; then + clear + # Note: prev/next is constrained to min/max + local after_delete_selection="$(next_version_installed "${selected}")" + if [[ "${after_delete_selection}" == "${selected}" ]]; then + after_delete_selection="$(prev_version_installed "${selected}")" + fi + remove_versions "${selected}" + + if [[ "${after_delete_selection}" == "${selected}" ]]; then + clear + leave_fullscreen + echo "All downloaded versions have been deleted from cache." + exit + fi + + selected="${after_delete_selection}" + display_versions_with_selected "${selected}" + fi + ;; + # Vim or Emacs 'up' key + "k"|"$CTRL_P") + clear + selected="$(prev_version_installed "${selected}")" + display_versions_with_selected "${selected}" + ;; + # Vim or Emacs 'down' key + "j"|"$CTRL_N") + clear + selected="$(next_version_installed "${selected}")" + display_versions_with_selected "${selected}" + ;; + "q") + clear + leave_fullscreen + exit + ;; + "") + # enter key returns empty string + leave_fullscreen + [[ -n "${selected}" ]] && activate "${selected}" + exit + ;; + esac + done +} + +# +# Move up a line and erase. +# + +erase_line() { + printf "\033[1A\033[2K" +} + +# +# Disable PaX mprotect for +# + +disable_pax_mprotect() { + test -z "$1" && abort "binary required" + local binary="$1" + + # try to disable mprotect via XATTR_PAX header + local PAXCTL="$(PATH="/sbin:/usr/sbin:$PATH" command -v paxctl-ng 2>&1)" + local PAXCTL_ERROR=1 + if [ -x "$PAXCTL" ]; then + $PAXCTL -l && $PAXCTL -m "$binary" >/dev/null 2>&1 + PAXCTL_ERROR="$?" + fi + + # try to disable mprotect via PT_PAX header + if [ "$PAXCTL_ERROR" != 0 ]; then + PAXCTL="$(PATH="/sbin:/usr/sbin:$PATH" command -v paxctl 2>&1)" + if [ -x "$PAXCTL" ]; then + $PAXCTL -Cm "$binary" >/dev/null 2>&1 + fi + fi +} + +# +# clean_copy_folder +# + +clean_copy_folder() { + local source="$1" + local target="$2" + if [[ -d "${source}" ]]; then + rm -rf "${target}" + cp -fR "${source}" "${target}" + fi +} + +# +# Activate +# + +activate() { + local version="$1" + local dir="$CACHE_DIR/$version" + local original_node="$(command -v node)" + local installed_node="${N_PREFIX}/bin/node" + log "copying" "$version" + + + # Ideally we would just copy from cache to N_PREFIX, but there are some complications + # - various linux versions use symlinks for folders in /usr/local and also error when copy folder onto symlink + # - we have used cp for years, so keep using it for backwards compatibility (instead of say rsync) + # - we allow preserving npm + # - we want to be somewhat robust to changes in tarball contents, so use find instead of hard-code expected subfolders + # + # This code was purist and concise for a long time. + # Now twice as much code, but using same code path for all uses, and supporting more setups. + + # Copy lib before bin so symlink targets exist. + # lib + mkdir -p "$N_PREFIX/lib" + # Copy everything except node_modules. + find "$dir/lib" -mindepth 1 -maxdepth 1 \! -name node_modules -exec cp -fR "{}" "$N_PREFIX/lib" \; + if [[ -z "${N_PRESERVE_NPM}" ]]; then + mkdir -p "$N_PREFIX/lib/node_modules" + # Copy just npm, skipping possible added global modules after download. Clean copy to avoid version change problems. + clean_copy_folder "$dir/lib/node_modules/npm" "$N_PREFIX/lib/node_modules/npm" + fi + # Takes same steps for corepack (experimental in node 16.9.0) as for npm, to avoid version problems. + if [[ -e "$dir/lib/node_modules/corepack" && -z "${N_PRESERVE_COREPACK}" ]]; then + mkdir -p "$N_PREFIX/lib/node_modules" + clean_copy_folder "$dir/lib/node_modules/corepack" "$N_PREFIX/lib/node_modules/corepack" + fi + + # bin + mkdir -p "$N_PREFIX/bin" + # Remove old node to avoid potential problems with firewall getting confused on Darwin by overwrite. + rm -f "$N_PREFIX/bin/node" + # Copy bin items by hand, in case user has installed global npm modules into cache. + cp -f "$dir/bin/node" "$N_PREFIX/bin" + [[ -e "$dir/bin/node-waf" ]] && cp -f "$dir/bin/node-waf" "$N_PREFIX/bin" # v0.8.x + if [[ -z "${N_PRESERVE_COREPACK}" ]]; then + [[ -e "$dir/bin/corepack" ]] && cp -fR "$dir/bin/corepack" "$N_PREFIX/bin" # from 16.9.0 + fi + if [[ -z "${N_PRESERVE_NPM}" ]]; then + [[ -e "$dir/bin/npm" ]] && cp -fR "$dir/bin/npm" "$N_PREFIX/bin" + [[ -e "$dir/bin/npx" ]] && cp -fR "$dir/bin/npx" "$N_PREFIX/bin" + fi + + # include + mkdir -p "$N_PREFIX/include" + find "$dir/include" -mindepth 1 -maxdepth 1 -exec cp -fR "{}" "$N_PREFIX/include" \; + + # share + mkdir -p "$N_PREFIX/share" + # Copy everything except man, at it is a symlink on some Linux (e.g. archlinux). + find "$dir/share" -mindepth 1 -maxdepth 1 \! -name man -exec cp -fR "{}" "$N_PREFIX/share" \; + mkdir -p "$N_PREFIX/share/man" + find "$dir/share/man" -mindepth 1 -maxdepth 1 -exec cp -fR "{}" "$N_PREFIX/share/man" \; + + disable_pax_mprotect "${installed_node}" + + local active_node="$(command -v node)" + if [[ -e "${active_node}" && -e "${installed_node}" && "${active_node}" != "${installed_node}" ]]; then + # Installed and active are different which might be a PATH problem. List both to give user some clues. + log "installed" "$("${installed_node}" --version) to ${installed_node}" + log "active" "$("${active_node}" --version) at ${active_node}" + else + local npm_version_str="" + local installed_npm="${N_PREFIX}/bin/npm" + local active_npm="$(command -v npm)" + if [[ -z "${N_PRESERVE_NPM}" && -e "${active_npm}" && -e "${installed_npm}" && "${active_npm}" = "${installed_npm}" ]]; then + npm_version_str=" (with npm $(npm --version))" + fi + + log "installed" "$("${installed_node}" --version)${npm_version_str}" + + # Extra tips for changed location. + if [[ -e "${active_node}" && -e "${original_node}" && "${active_node}" != "${original_node}" ]]; then + printf '\nNote: the node command changed location and the old location may be remembered in your current shell.\n' + log old "${original_node}" + log new "${active_node}" + printf 'If "node --version" shows the old version then start a new shell, or reset the location hash with:\nhash -r (for bash, zsh, ash, dash, and ksh)\nrehash (for csh and tcsh)\n' + fi + fi +} + +# +# Install +# + +install() { + [[ -z "$1" ]] && abort "version required" + local version + get_latest_resolved_version "$1" || return 2 + version="${g_target_node}" + [[ -n "${version}" ]] || abort "no version found for '$1'" + update_mirror_settings_for_version "$1" + update_xz_settings_for_version "${version}" + update_arch_settings_for_version "${version}" + + local dir="${CACHE_DIR}/${g_mirror_folder_name}/${version}" + + # Note: decompression flags ignored with default Darwin tar which autodetects. + if test "$N_USE_XZ" = "true"; then + local tarflag="-Jx" + else + local tarflag="-zx" + fi + + if test -d "$dir"; then + if [[ ! -e "$dir/n.lock" ]] ; then + if [[ "$DOWNLOAD" == "false" ]] ; then + activate "${g_mirror_folder_name}/${version}" + fi + exit + fi + fi + + log installing "${g_mirror_folder_name}-v$version" + + local url="$(tarball_url "$version")" + is_ok "${url}" || abort "download preflight failed for '$version' (${url})" + + log mkdir "$dir" + mkdir -p "$dir" || abort "sudo required (or change ownership, or define N_PREFIX)" + touch "$dir/n.lock" + + cd "${dir}" || abort "Failed to cd to ${dir}" + + log fetch "$url" + do_get "${url}" | tar "$tarflag" --strip-components=1 --no-same-owner -f - + pipe_results=( "${PIPESTATUS[@]}" ) + if [[ "${pipe_results[0]}" -ne 0 ]]; then + abort "failed to download archive for $version" + fi + if [[ "${pipe_results[1]}" -ne 0 ]]; then + abort "failed to extract archive for $version" + fi + [ "$GET_SHOWS_PROGRESS" = "true" ] && erase_line + rm -f "$dir/n.lock" + + disable_pax_mprotect bin/node + + if [[ "$DOWNLOAD" == "false" ]]; then + activate "${g_mirror_folder_name}/$version" + fi +} + +# +# Be more silent. +# + +set_quiet() { + SHOW_VERBOSE_LOG="false" + command -v curl > /dev/null && CURL_OPTIONS+=( "--silent" ) && GET_SHOWS_PROGRESS="false" +} + +# +# Synopsis: do_get [option...] url +# Call curl or wget with combination of global and passed options. +# + +function do_get() { + if command -v curl &> /dev/null; then + curl "${CURL_OPTIONS[@]}" "$@" + elif command -v wget &> /dev/null; then + wget "${WGET_OPTIONS[@]}" "$@" + else + abort "curl or wget command required" + fi +} + +# +# Synopsis: do_get_index [option...] url +# Call curl or wget with combination of global and passed options, +# with options tweaked to be more suitable for getting index. +# + +function do_get_index() { + if command -v curl &> /dev/null; then + # --silent to suppress progress et al + curl --silent --compressed "${CURL_OPTIONS[@]}" "$@" + elif command -v wget &> /dev/null; then + wget "${WGET_OPTIONS[@]}" "$@" + else + abort "curl or wget command required" + fi +} + +# +# Synopsis: remove_versions version ... +# + +function remove_versions() { + [[ -z "$1" ]] && abort "version(s) required" + while [[ $# -ne 0 ]]; do + local version + get_latest_resolved_version "$1" || break + version="${g_target_node}" + if [[ -n "${version}" ]]; then + update_mirror_settings_for_version "$1" + local dir="${CACHE_DIR}/${g_mirror_folder_name}/${version}" + if [[ -s "${dir}" ]]; then + rm -rf "${dir}" + else + echo "$1 (${version}) not in downloads cache" + fi + else + echo "No version found for '$1'" + fi + shift + done +} + +# +# Synopsis: prune_cache +# + +function prune_cache() { + set_active_node + + for folder_and_version in $(display_versions_paths); do + if [[ "${folder_and_version}" != "${g_active_node}" ]]; then + echo "${folder_and_version}" + rm -rf "${CACHE_DIR:?}/${folder_and_version}" + fi + done +} + +# +# Synopsis: find_cached_version version +# Finds cache directory for resolved version. +# Globals modified: +# - g_cached_version + +function find_cached_version() { + [[ -z "$1" ]] && abort "version required" + local version + get_latest_resolved_version "$1" || exit 1 + version="${g_target_node}" + [[ -n "${version}" ]] || abort "no version found for '$1'" + + update_mirror_settings_for_version "$1" + g_cached_version="${CACHE_DIR}/${g_mirror_folder_name}/${version}" + if [[ ! -d "${g_cached_version}" && "${DOWNLOAD}" == "true" ]]; then + (install "${version}") + fi + [[ -d "${g_cached_version}" ]] || abort "'$1' (${version}) not in downloads cache" +} + + +# +# Synopsis: display_bin_path_for_version version +# + +function display_bin_path_for_version() { + find_cached_version "$1" + echo "${g_cached_version}/bin/node" +} + +# +# Synopsis: run_with_version version [args...] +# Run the given of node with [args ..] +# + +function run_with_version() { + find_cached_version "$1" + shift # remove version from parameters + exec "${g_cached_version}/bin/node" "$@" +} + +# +# Synopsis: exec_with_version command [args...] +# Modify the path to include and execute command. +# + +function exec_with_version() { + find_cached_version "$1" + shift # remove version from parameters + PATH="${g_cached_version}/bin:$PATH" exec "$@" +} + +# +# Synopsis: is_ok url +# Check the HEAD response of . +# + +function is_ok() { + # Note: both curl and wget can follow redirects, as present on some mirrors (e.g. https://npm.taobao.org/mirrors/node). + # The output is complicated with redirects, so keep it simple and use command status rather than parse output. + if command -v curl &> /dev/null; then + do_get --silent --head "$1" > /dev/null || return 1 + else + do_get --spider "$1" > /dev/null || return 1 + fi +} + +# +# Synopsis: can_use_xz +# Test system to see if xz decompression is supported by tar. +# + +function can_use_xz() { + # Be conservative and only enable if xz is likely to work. Unfortunately we can't directly query tar itself. + # For research, see https://github.com/shadowspawn/nvh/issues/8 + local uname_s="$(uname -s)" + if [[ "${uname_s}" = "Linux" ]] && command -v xz &> /dev/null ; then + # tar on linux is likely to support xz if it is available as a command + return 0 + elif [[ "${uname_s}" = "Darwin" ]]; then + local macos_version="$(sw_vers -productVersion)" + local macos_major_version="$(echo "${macos_version}" | cut -d '.' -f 1)" + local macos_minor_version="$(echo "${macos_version}" | cut -d '.' -f 2)" + if [[ "${macos_major_version}" -gt 10 || "${macos_minor_version}" -gt 8 ]]; then + # tar on recent Darwin has xz support built-in + return 0 + fi + fi + return 2 # not supported +} + +# +# Synopsis: display_tarball_platform +# + +function display_tarball_platform() { + # https://en.wikipedia.org/wiki/Uname + + local os="unexpected_os" + local uname_a="$(uname -a)" + case "${uname_a}" in + Linux*) os="linux" ;; + Darwin*) os="darwin" ;; + SunOS*) os="sunos" ;; + AIX*) os="aix" ;; + CYGWIN*) >&2 echo_red "Cygwin is not supported by n" ;; + MINGW*) >&2 echo_red "Git BASH (MSYS) is not supported by n" ;; + esac + + local arch="unexpected_arch" + local uname_m="$(uname -m)" + case "${uname_m}" in + x86_64) arch=x64 ;; + i386 | i686) arch="x86" ;; + aarch64) arch=arm64 ;; + armv8l) arch=arm64 ;; # armv8l probably supports arm64, and there is no specific armv8l build so give it a go + *) + # e.g. armv6l, armv7l, arm64 + arch="${uname_m}" + ;; + esac + # Override from command line, or version specific adjustment. + [ -n "$ARCH" ] && arch="$ARCH" + + echo "${os}-${arch}" +} + +# +# Synopsis: display_compatible_file_field +# display for current platform, as per field in index.tab, which is different than actual download +# + +function display_compatible_file_field { + local compatible_file_field="$(display_tarball_platform)" + if [[ -z "${ARCH}" && "${compatible_file_field}" = "darwin-arm64" ]]; then + # Look for arm64 for native but also x64 for older versions which can run in rosetta. + # (Downside is will get an install error if install version above 16 with x64 and not arm64.) + compatible_file_field="osx-arm64-tar|osx-x64-tar" + elif [[ "${compatible_file_field}" =~ darwin-(.*) ]]; then + compatible_file_field="osx-${BASH_REMATCH[1]}-tar" + fi + echo "${compatible_file_field}" +} + +# +# Synopsis: tarball_url version +# + +function tarball_url() { + local version="$1" + local ext=gz + [ "$N_USE_XZ" = "true" ] && ext="xz" + echo "${g_mirror_url}/v${version}/node-v${version}-$(display_tarball_platform).tar.${ext}" +} + +# +# Synopsis: get_file_node_version filename +# Sets g_target_node +# + +function get_file_node_version() { + g_target_node= + local filepath="$1" + verbose_log "found" "${filepath}" + # read returns a non-zero status but does still work if there is no line ending + local version + <"${filepath}" read -r version + # trim possible trailing \d from a Windows created file + version="${version%%[[:space:]]}" + verbose_log "read" "${version}" + g_target_node="${version}" +} + +# +# Synopsis: get_package_engine_version\ +# Sets g_target_node +# + +function get_package_engine_version() { + g_target_node= + local filepath="$1" + verbose_log "found" "${filepath}" + command -v node &> /dev/null || abort "an active version of node is required to read 'engines' from package.json" + local range + range="$(node -e "package = require('${filepath}'); if (package && package.engines && package.engines.node) console.log(package.engines.node)")" + verbose_log "read" "${range}" + [[ -n "${range}" ]] || return 2 + if [[ "*" == "${range}" ]]; then + verbose_log "target" "current" + g_target_node="current" + return + fi + + local version + if [[ "${range}" =~ ^([>~^=]|\>\=)?v?([0-9]+(\.[0-9]+){0,2})(.[xX*])?$ ]]; then + local operator="${BASH_REMATCH[1]}" + version="${BASH_REMATCH[2]}" + case "${operator}" in + '' | =) ;; + \> | \>=) version="current" ;; + \~) [[ "${version}" =~ ^([0-9]+\.[0-9]+)\.[0-9]+$ ]] && version="${BASH_REMATCH[1]}" ;; + ^) [[ "${version}" =~ ^([0-9]+) ]] && version="${BASH_REMATCH[1]}" ;; + esac + verbose_log "target" "${version}" + else + command -v npx &> /dev/null || abort "an active version of npx is required to use complex 'engine' ranges from package.json" + verbose_log "resolving" "${range}" + local version_per_line="$(n lsr --all)" + local versions_one_line=$(echo "${version_per_line}" | tr '\n' ' ') + # Using semver@7 so works with older versions of node. + # shellcheck disable=SC2086 + version=$(npm_config_yes=true npx --quiet semver@7 -r "${range}" ${versions_one_line} | tail -n 1) + fi + g_target_node="${version}" +} + +# +# Synopsis: get_nvmrc_version +# Sets g_target_node +# + +function get_nvmrc_version() { + g_target_node= + local filepath="$1" + verbose_log "found" "${filepath}" + local version + <"${filepath}" read -r version + verbose_log "read" "${version}" + # Translate from nvm aliases + case "${version}" in + lts/\*) version="lts" ;; + lts/*) version="${version:4}" ;; + node) version="current" ;; + *) ;; + esac + g_target_node="${version}" +} + +# +# Synopsis: get_engine_version [error-message] +# Sets g_target_node +# + +function get_engine_version() { + g_target_node= + local error_message="${1-package.json not found}" + local parent + parent="${PWD}" + while [[ -n "${parent}" ]]; do + if [[ -e "${parent}/package.json" ]]; then + get_package_engine_version "${parent}/package.json" + else + parent=${parent%/*} + continue + fi + break + done + [[ -n "${parent}" ]] || abort "${error_message}" + [[ -n "${g_target_node}" ]] || abort "did not find supported version of node in 'engines' field of package.json" +} + +# +# Synopsis: get_auto_version +# Sets g_target_node +# + +function get_auto_version() { + g_target_node= + # Search for a version control file first + local parent + parent="${PWD}" + while [[ -n "${parent}" ]]; do + if [[ -e "${parent}/.n-node-version" ]]; then + get_file_node_version "${parent}/.n-node-version" + elif [[ -e "${parent}/.node-version" ]]; then + get_file_node_version "${parent}/.node-version" + elif [[ -e "${parent}/.nvmrc" ]]; then + get_nvmrc_version "${parent}/.nvmrc" + else + parent=${parent%/*} + continue + fi + break + done + # Fallback to package.json + [[ -n "${parent}" ]] || get_engine_version "no file found for auto version (.n-node-version, .node-version, .nvmrc, or package.json)" + [[ -n "${g_target_node}" ]] || abort "file found for auto did not contain target version of node" +} + +# +# Synopsis: get_latest_resolved_version version +# Sets g_target_node +# + +function get_latest_resolved_version() { + g_target_node= + local version=${1} + simple_version=${version#node/} # Only place supporting node/ [sic] + if is_exact_numeric_version "${simple_version}"; then + # Just numbers, already resolved, no need to lookup first. + simple_version="${simple_version#v}" + g_target_node="${simple_version}" + else + # Complicated recognising exact version, KISS and lookup. + g_target_node=$(N_MAX_REMOTE_MATCHES=1 display_remote_versions "$version") + fi +} + +# +# Synopsis: display_remote_index +# index.tab reference: https://github.com/nodejs/nodejs-dist-indexer +# Index fields are: version date files npm v8 uv zlib openssl modules lts security +# KISS and just return fields we currently care about: version files lts +# + +display_remote_index() { + local index_url="${g_mirror_url}/index.tab" + # tail to remove header line + do_get_index "${index_url}" | tail -n +2 | cut -f 1,3,10 + if [[ "${PIPESTATUS[0]}" -ne 0 ]]; then + # Reminder: abort will only exit subshell, but consistent error display + abort "failed to download version index (${index_url})" + fi +} + +# +# Synopsis: display_match_limit limit +# + +function display_match_limit(){ + if [[ "$1" -gt 1 && "$1" -lt 32000 ]]; then + echo "Listing remote... Displaying $1 matches (use --all to see all)." + fi +} + +# +# Synopsis: display_remote_versions version +# + +function display_remote_versions() { + local version="$1" + update_mirror_settings_for_version "${version}" + local match='.' + local match_count="${N_MAX_REMOTE_MATCHES}" + + # Transform some labels before processing further. + if is_node_support_version "${version}"; then + version="$(display_latest_node_support_alias "${version}")" + match_count=1 + elif [[ "${version}" = "auto" ]]; then + # suppress stdout logging so lsr layout same as usual for scripting + get_auto_version || return 2 + version="${g_target_node}" + elif [[ "${version}" = "engine" ]]; then + # suppress stdout logging so lsr layout same as usual for scripting + get_engine_version || return 2 + version="${g_target_node}" + fi + + if [[ -z "${version}" ]]; then + match='.' + elif [[ "${version}" = "lts" || "${version}" = "stable" ]]; then + match_count=1 + # Codename is last field, first one with a name is newest lts + match="${TAB_CHAR}[a-zA-Z]+\$" + elif [[ "${version}" = "latest" || "${version}" = "current" ]]; then + match_count=1 + match='.' + elif is_numeric_version "${version}"; then + version="v${version#v}" + # Avoid restriction message if exact version + is_exact_numeric_version "${version}" && match_count=1 + # Quote any dots in version so they are literal for expression + match="${version//\./\.}" + # Avoid 1.2 matching 1.23 + match="^${match}[^0-9]" + elif is_lts_codename "${version}"; then + # Capitalise (could alternatively make grep case insensitive) + codename="$(echo "${version:0:1}" | tr '[:lower:]' '[:upper:]')${version:1}" + # Codename is last field + match="${TAB_CHAR}${codename}\$" + elif is_download_folder "${version}"; then + match='.' + elif is_download_version "${version}"; then + version="${version#"${g_mirror_folder_name}"/}" + if [[ "${version}" = "latest" || "${version}" = "current" ]]; then + match_count=1 + match='.' + else + version="v${version#v}" + match="${version//\./\.}" + match="^${match}" # prefix + if is_numeric_version "${version}"; then + # Exact numeric match + match="${match}[^0-9]" + fi + fi + else + abort "invalid version '$1'" + fi + display_match_limit "${match_count}" + + # Implementation notes: + # - using awk rather than head so do not close pipe early on curl + # - restrict search to compatible files as not always available, or not at same time + # - return status of curl command (i.e. PIPESTATUS[0]) + display_remote_index \ + | n_grep -E "$(display_compatible_file_field)" \ + | n_grep -E "${match}" \ + | awk "NR<=${match_count}" \ + | cut -f 1 \ + | n_grep -E -o '[^v].*' + return "${PIPESTATUS[0]}" +} + +# +# Synopsis: delete_with_echo target +# + +function delete_with_echo() { + if [[ -e "$1" ]]; then + echo "$1" + rm -rf "$1" + fi +} + +# +# Synopsis: uninstall_installed +# Uninstall the installed node and npm (leaving alone the cache), +# so undo install, and may expose possible system installed versions. +# + +uninstall_installed() { + # npm: https://docs.npmjs.com/misc/removing-npm + # rm -rf /usr/local/{lib/node{,/.npm,_modules},bin,share/man}/npm* + # node: https://stackabuse.com/how-to-uninstall-node-js-from-mac-osx/ + # Doing it by hand rather than scanning cache, so still works if cache deleted first. + # This covers tarballs for at least node 4 through 10. + + while true; do + read -r -p "Do you wish to delete node and npm from ${N_PREFIX}? " yn + case $yn in + [Yy]* ) break ;; + [Nn]* ) exit ;; + * ) echo "Please answer yes or no.";; + esac + done + + echo "" + echo "Uninstalling node and npm" + delete_with_echo "${N_PREFIX}/bin/node" + delete_with_echo "${N_PREFIX}/bin/npm" + delete_with_echo "${N_PREFIX}/bin/npx" + delete_with_echo "${N_PREFIX}/bin/corepack" + delete_with_echo "${N_PREFIX}/include/node" + delete_with_echo "${N_PREFIX}/lib/dtrace/node.d" + delete_with_echo "${N_PREFIX}/lib/node_modules/npm" + delete_with_echo "${N_PREFIX}/lib/node_modules/corepack" + delete_with_echo "${N_PREFIX}/share/doc/node" + delete_with_echo "${N_PREFIX}/share/man/man1/node.1" + delete_with_echo "${N_PREFIX}/share/systemtap/tapset/node.stp" +} + +# +# Synopsis: show_permission_suggestions +# + +function show_permission_suggestions() { + echo "Suggestions:" + echo "- run n with sudo, or" + echo "- define N_PREFIX to a writeable location, or" +} + +# +# Synopsis: show_diagnostics +# Show environment and check for common problems. +# + +function show_diagnostics() { + echo "This information is to help you diagnose issues, and useful when reporting an issue." + echo "Note: some output may contain passwords. Redact before sharing." + + printf "\n\nCOMMAND LOCATIONS AND VERSIONS\n" + + printf "\nbash\n" + command -v bash && bash --version + + printf "\nn\n" + command -v n && n --version + + printf "\nnode\n" + if command -v node &> /dev/null; then + command -v node && node --version + node -e 'if (process.versions.v8) console.log("JavaScript engine: v8");' + + printf "\nnpm\n" + command -v npm && npm --version + fi + + printf "\ntar\n" + if command -v tar &> /dev/null; then + command -v tar && tar --version + else + echo_red "tar not found. Needed for extracting downloads." + fi + + printf "\ncurl or wget\n" + if command -v curl &> /dev/null; then + command -v curl && curl --version + elif command -v wget &> /dev/null; then + command -v wget && wget --version + else + echo_red "Neither curl nor wget found. Need one of them for downloads." + fi + + printf "\nuname\n" + uname -a + + printf "\n\nSETTINGS\n" + + printf "\nn\n" + echo "node mirror: ${N_NODE_MIRROR}" + echo "node downloads mirror: ${N_NODE_DOWNLOAD_MIRROR}" + echo "install destination: ${N_PREFIX}" + [[ -n "${N_PREFIX}" ]] && echo "PATH: ${PATH}" + echo "ls-remote max matches: ${N_MAX_REMOTE_MATCHES}" + [[ -n "${N_PRESERVE_NPM}" ]] && echo "installs preserve npm by default" + [[ -n "${N_PRESERVE_COREPACK}" ]] && echo "installs preserve corepack by default" + + printf "\nProxy\n" + # disable "var is referenced but not assigned": https://github.com/koalaman/shellcheck/wiki/SC2154 + # shellcheck disable=SC2154 + [[ -n "${http_proxy}" ]] && echo "http_proxy: ${http_proxy}" + # shellcheck disable=SC2154 + [[ -n "${https_proxy}" ]] && echo "https_proxy: ${https_proxy}" + if command -v curl &> /dev/null; then + # curl supports lower case and upper case! + # shellcheck disable=SC2154 + [[ -n "${all_proxy}" ]] && echo "all_proxy: ${all_proxy}" + [[ -n "${ALL_PROXY}" ]] && echo "ALL_PROXY: ${ALL_PROXY}" + [[ -n "${HTTP_PROXY}" ]] && echo "HTTP_PROXY: ${HTTP_PROXY}" + [[ -n "${HTTPS_PROXY}" ]] && echo "HTTPS_PROXY: ${HTTPS_PROXY}" + if [[ -e "${CURL_HOME}/.curlrc" ]]; then + echo "have \$CURL_HOME/.curlrc" + elif [[ -e "${HOME}/.curlrc" ]]; then + echo "have \$HOME/.curlrc" + fi + elif command -v wget &> /dev/null; then + if [[ -e "${WGETRC}" ]]; then + echo "have \$WGETRC" + elif [[ -e "${HOME}/.wgetrc" ]]; then + echo "have \$HOME/.wgetrc" + fi + fi + + printf "\n\nCHECKS\n" + + printf "\nChecking n install destination is in PATH...\n" + local install_bin="${N_PREFIX}/bin" + local path_wth_guards=":${PATH}:" + if [[ "${path_wth_guards}" =~ :${install_bin}/?: ]]; then + printf "good\n" + else + echo_red "'${install_bin}' is not in PATH" + fi + if command -v node &> /dev/null; then + printf "\nChecking n install destination priority in PATH...\n" + local node_dir="$(dirname "$(command -v node)")" + + local index=0 + local path_entry + local path_entries + local install_bin_index=0 + local node_index=999 + IFS=':' read -ra path_entries <<< "${PATH}" + for path_entry in "${path_entries[@]}"; do + (( index++ )) + [[ "${path_entry}" =~ ^${node_dir}/?$ ]] && node_index="${index}" + [[ "${path_entry}" =~ ^${install_bin}/?$ ]] && install_bin_index="${index}" + done + if [[ "${node_index}" -lt "${install_bin_index}" ]]; then + echo_red "There is a version of node installed which will be found in PATH before the n installed version." + else + printf "good\n" + fi + fi + + printf "\nChecking permissions for cache folder...\n" + # Most likely problem is ownership rather than than permissions as such. + local cache_root="${N_PREFIX}/n" + if [[ -e "${N_PREFIX}" && ! -w "${N_PREFIX}" && ! -e "${cache_root}" ]]; then + echo_red "You do not have write permission to create: ${cache_root}" + show_permission_suggestions + echo "- make a folder you own:" + echo " sudo mkdir -p \"${cache_root}\"" + echo " sudo chown $(whoami) \"${cache_root}\"" + elif [[ -e "${cache_root}" && ! -w "${cache_root}" ]]; then + echo_red "You do not have write permission to: ${cache_root}" + show_permission_suggestions + echo "- change folder ownership to yourself:" + echo " sudo chown -R $(whoami) \"${cache_root}\"" + elif [[ ! -e "${cache_root}" ]]; then + echo "Cache folder does not exist: ${cache_root}" + echo "This is normal if you have not done an install yet, as cache is only created when needed." + elif [[ -e "${CACHE_DIR}" && ! -w "${CACHE_DIR}" ]]; then + echo_red "You do not have write permission to: ${CACHE_DIR}" + show_permission_suggestions + echo "- change folder ownership to yourself:" + echo " sudo chown -R $(whoami) \"${CACHE_DIR}\"" + else + echo "good" + fi + + if [[ -e "${N_PREFIX}" ]]; then + # Most likely problem is ownership rather than than permissions as such. + printf "\nChecking permissions for install folders...\n" + local install_writeable="true" + for subdir in bin lib include share; do + if [[ -e "${N_PREFIX}/${subdir}" && ! -w "${N_PREFIX}/${subdir}" ]]; then + install_writeable="false" + echo_red "You do not have write permission to: ${N_PREFIX}/${subdir}" + break + fi + done + if [[ "${install_writeable}" = "true" ]]; then + echo "good" + else + show_permission_suggestions + echo "- change folder ownerships to yourself:" + echo " (cd \"${N_PREFIX}\" && sudo chown -R $(whoami) bin lib include share)" + fi + fi + + printf "\nChecking mirror is reachable...\n" + if is_ok "${N_NODE_MIRROR}/"; then + printf "good\n" + else + echo_red "mirror not reachable" + printf "Showing failing command and output\n" + if command -v curl &> /dev/null; then + ( set -x; do_get --head "${N_NODE_MIRROR}/" ) + else + ( set -x; do_get --spider "${N_NODE_MIRROR}/" ) + printf "\n" + fi + fi +} + +# +# Handle arguments. +# + +# First pass. Process the options so they can come before or after commands, +# particularly for `n lsr --all` and `n install --arch x686` +# which feel pretty natural. + +unprocessed_args=() +positional_arg="false" + +while [[ $# -ne 0 ]]; do + case "$1" in + --all) N_MAX_REMOTE_MATCHES=32000 ;; + -V|--version) display_n_version ;; + -h|--help|help) display_help; exit ;; + -q|--quiet) set_quiet ;; + -d|--download) DOWNLOAD="true" ;; + --insecure) set_insecure ;; + -p|--preserve) N_PRESERVE_NPM="true" N_PRESERVE_COREPACK="true" ;; + --no-preserve) N_PRESERVE_NPM="" N_PRESERVE_COREPACK="" ;; + --use-xz) N_USE_XZ="true" ;; + --no-use-xz) N_USE_XZ="false" ;; + --latest) display_remote_versions latest; exit ;; + --stable) display_remote_versions lts; exit ;; # [sic] old terminology + --lts) display_remote_versions lts; exit ;; + -a|--arch) shift; set_arch "$1";; # set arch and continue + exec|run|as|use) + unprocessed_args+=( "$1" ) + positional_arg="true" + ;; + *) + if [[ "${positional_arg}" == "true" ]]; then + unprocessed_args+=( "$@" ) + break + fi + unprocessed_args+=( "$1" ) + ;; + esac + shift +done + +if [[ -z "${N_USE_XZ+defined}" ]]; then + N_USE_XZ="true" # Default to using xz + can_use_xz || N_USE_XZ="false" +fi + +set -- "${unprocessed_args[@]}" + +if test $# -eq 0; then + test -z "$(display_versions_paths)" && err_no_installed_print_help + menu_select_cache_versions +else + while test $# -ne 0; do + case "$1" in + bin|which) display_bin_path_for_version "$2"; exit ;; + run|as|use) shift; run_with_version "$@"; exit ;; + exec) shift; exec_with_version "$@"; exit ;; + doctor) show_diagnostics; exit ;; + rm|-) shift; remove_versions "$@"; exit ;; + prune) prune_cache; exit ;; + latest) install latest; exit ;; + stable) install stable; exit ;; + lts) install lts; exit ;; + ls|list) display_versions_paths; exit ;; + lsr|ls-remote|list-remote) shift; display_remote_versions "$1"; exit ;; + uninstall) uninstall_installed; exit ;; + i|install) shift; install "$1"; exit ;; + N_TEST_DISPLAY_LATEST_RESOLVED_VERSION) shift; get_latest_resolved_version "$1" > /dev/null || exit 2; echo "${g_target_node}"; exit ;; + *) install "$1"; exit ;; + esac + shift + done +fi diff --git a/hooks/backup/18-data_multimedia b/hooks/backup/18-data_multimedia index f80cff0b3..94162d517 100644 --- a/hooks/backup/18-data_multimedia +++ b/hooks/backup/18-data_multimedia @@ -9,7 +9,7 @@ source /usr/share/yunohost/helpers # Backup destination backup_dir="${1}/data/multimedia" -if [ -e "/home/yunohost.multimedia/.nobackup" ]; then +if [ ! -e "/home/yunohost.multimedia" ] || [ -e "/home/yunohost.multimedia/.nobackup" ]; then exit 0 fi diff --git a/hooks/conf_regen/01-yunohost b/hooks/conf_regen/01-yunohost index 51022a4e5..d0e6fb783 100755 --- a/hooks/conf_regen/01-yunohost +++ b/hooks/conf_regen/01-yunohost @@ -125,12 +125,15 @@ EOF fi # Skip ntp if inside a container (inspired from the conf of systemd-timesyncd) - mkdir -p ${pending_dir}/etc/systemd/system/ntp.service.d/ - cat >${pending_dir}/etc/systemd/system/ntp.service.d/ynh-override.conf <${pending_dir}/etc/systemd/system/ntp.service.d/ynh-override.conf </dev/null)" ]] + then + if systemctl is-enabled metronome &>/dev/null + then + systemctl disable metronome --now 2>/dev/null + fi + else + if ! systemctl is-enabled metronome &>/dev/null + then + systemctl enable metronome --now 2>/dev/null + sleep 3 + fi + + [[ -z "$regen_conf_files" ]] \ + || systemctl restart metronome + fi } do_$1_regen ${@:2} diff --git a/hooks/conf_regen/25-dovecot b/hooks/conf_regen/25-dovecot index adbb7761e..49ff0c9ba 100755 --- a/hooks/conf_regen/25-dovecot +++ b/hooks/conf_regen/25-dovecot @@ -16,7 +16,7 @@ do_pre_regen() { cp dovecot-ldap.conf "${dovecot_dir}/dovecot-ldap.conf" cp dovecot.sieve "${dovecot_dir}/global_script/dovecot.sieve" - export pop3_enabled="$(yunohost settings get 'email.pop3.pop3_enabled')" + export pop3_enabled="$(yunohost settings get 'email.pop3.pop3_enabled' | int_to_bool)" export main_domain=$(cat /etc/yunohost/current_host) export domain_list="$(yunohost domain list --features mail_in mail_out --output-as json | jq -r ".domains[]" | tr '\n' ' ')" diff --git a/hooks/conf_regen/35-postgresql b/hooks/conf_regen/35-postgresql index 0da0767cc..3a3843d69 100755 --- a/hooks/conf_regen/35-postgresql +++ b/hooks/conf_regen/35-postgresql @@ -20,7 +20,7 @@ do_pre_regen() { } do_post_regen() { - regen_conf_files=$1 + #regen_conf_files=$1 # Make sure postgresql is started and enabled # (N.B. : to check the active state, we check the cluster state because @@ -34,6 +34,8 @@ do_post_regen() { if [ ! -f "$PSQL_ROOT_PWD_FILE" ] || [ -z "$(cat $PSQL_ROOT_PWD_FILE)" ]; then ynh_string_random >$PSQL_ROOT_PWD_FILE fi + + [ ! -e $PSQL_ROOT_PWD_FILE ] || { chown root:postgres $PSQL_ROOT_PWD_FILE; chmod 440 $PSQL_ROOT_PWD_FILE; } sudo --login --user=postgres psql -c"ALTER user postgres WITH PASSWORD '$(cat $PSQL_ROOT_PWD_FILE)'" postgres @@ -47,20 +49,4 @@ do_post_regen() { ynh_systemd_action --service_name=postgresql --action=reload } -FORCE=${2:-0} -DRY_RUN=${3:-0} - -case "$1" in - pre) - do_pre_regen $4 - ;; - post) - do_post_regen $4 - ;; - *) - echo "hook called with unknown argument \`$1'" >&2 - exit 1 - ;; -esac - -exit 0 +do_$1_regen ${@:2} diff --git a/locales/ar.json b/locales/ar.json index 673176cdf..d26a7802d 100644 --- a/locales/ar.json +++ b/locales/ar.json @@ -5,16 +5,16 @@ "app_already_up_to_date": "{app} حديثٌ", "app_argument_required": "المُعامِل '{name}' مطلوب", "app_extraction_failed": "تعذر فك الضغط عن ملفات التنصيب", - "app_install_files_invalid": "ملفات التنصيب خاطئة", + "app_install_files_invalid": "لا يمكن تنصيب هذه الملفات", "app_not_correctly_installed": "يبدو أن التطبيق {app} لم يتم تنصيبه بشكل صحيح", "app_not_installed": "إنّ التطبيق {app} غير مُنصَّب", "app_not_properly_removed": "لم يتم حذف تطبيق {app} بشكلٍ جيّد", "app_removed": "تمت إزالة تطبيق {app}", - "app_requirements_checking": "جار فحص الحزم اللازمة لـ {app}…", - "app_sources_fetch_failed": "تعذرت عملية جلب مصادر الملفات", + "app_requirements_checking": "جار فحص متطلبات تطبيق {app}…", + "app_sources_fetch_failed": "تعذر جلب ملفات المصدر ، هل عنوان URL صحيح؟", "app_unknown": "برنامج مجهول", "app_upgrade_app_name": "جارٍ تحديث {app}…", - "app_upgrade_failed": "تعذرت عملية ترقية {app}", + "app_upgrade_failed": "تعذرت عملية تحديث {app}: {error}", "app_upgrade_some_app_failed": "تعذرت عملية ترقية بعض التطبيقات", "app_upgraded": "تم تحديث التطبيق {app}", "ask_main_domain": "النطاق الرئيسي", @@ -22,7 +22,7 @@ "ask_password": "كلمة السر", "backup_applying_method_copy": "جارٍ نسخ كافة الملفات المراد نسخها احتياطيا …", "backup_applying_method_tar": "جارٍ إنشاء ملف TAR للنسخة الاحتياطية…", - "backup_created": "تم إنشاء النسخة الإحتياطية", + "backup_created": "تم إنشاء النسخة الإحتياطية: {name}", "backup_method_copy_finished": "إنتهت عملية النسخ الإحتياطي", "backup_nothings_done": "ليس هناك أي شيء للحفظ", "backup_output_directory_required": "يتوجب عليك تحديد مجلد لتلقي النسخ الإحتياطية", @@ -30,7 +30,7 @@ "certmanager_cert_install_success_selfsigned": "نجحت عملية تثبيت الشهادة الموقعة ذاتيا الخاصة بالنطاق '{domain}'", "certmanager_cert_renew_success": "نجحت عملية تجديد شهادة Let's Encrypt الخاصة باسم النطاق '{domain}'", "certmanager_cert_signing_failed": "فشل إجراء توقيع الشهادة الجديدة", - "certmanager_no_cert_file": "تعذرت عملية قراءة شهادة نطاق {domain} (الملف : {file})", + "certmanager_no_cert_file": "تعذرت عملية قراءة ملف شهادة نطاق {domain} (الملف : {file})", "domain_created": "تم إنشاء النطاق", "domain_creation_failed": "تعذرت عملية إنشاء النطاق {domain}: {error}", "domain_deleted": "تم حذف النطاق", @@ -39,7 +39,7 @@ "done": "تم", "downloading": "عملية التنزيل جارية …", "dyndns_ip_updated": "لقد تم تحديث عنوان الإيبي الخاص بك على نظام أسماء النطاقات الديناميكي", - "dyndns_key_generating": "عملية توليد مفتاح نظام أسماء النطاقات جارية. يمكن للعملية أن تستغرق بعضا من الوقت…", + "dyndns_key_generating": "جارٍ إنشاء مفتاح DNS ... قد يستغرق الأمر بعض الوقت.", "dyndns_key_not_found": "لم يتم العثور على مفتاح DNS الخاص باسم النطاق هذا", "extracting": "عملية فك الضغط جارية…", "installation_complete": "إكتملت عملية التنصيب", @@ -49,15 +49,15 @@ "pattern_domain": "يتوجب أن يكون إسم نطاق صالح (مثل my-domain.org)", "pattern_email": "يتوجب أن يكون عنوان بريد إلكتروني صالح (مثل someone@domain.org)", "pattern_password": "يتوجب أن تكون مكونة من 3 حروف على الأقل", - "restore_extracting": "جارٍ فك الضغط عن الملفات التي نحتاجها من النسخة الاحتياطية…", + "restore_extracting": "جارٍ فك الضغط عن الملفات اللازمة من النسخة الاحتياطية…", "server_shutdown": "سوف ينطفئ الخادوم", "server_shutdown_confirm": "سوف ينطفئ الخادوم حالا. متأكد ؟ [{answers}]", "server_reboot": "سيعاد تشغيل الخادوم", "server_reboot_confirm": "سيعاد تشغيل الخادوم في الحين. هل أنت متأكد ؟ [{answers}]", "service_add_failed": "تعذرت إضافة خدمة '{service}'", "service_already_stopped": "إنّ خدمة '{service}' متوقفة مِن قبلُ", - "service_disabled": "لن يتم إطلاق خدمة '{service}' أثناء بداية تشغيل النظام.", - "service_enabled": "تم تنشيط خدمة '{service}'", + "service_disabled": "لن يتم إطلاق خدمة '{service}' أثناء بداية تشغيل النظام بتاتا.", + "service_enabled": "سيتم الآن بدء تشغيل الخدمة '{service}' تلقائيًا أثناء تمهيد النظام.", "service_removed": "تمت إزالة خدمة '{service}'", "service_started": "تم إطلاق تشغيل خدمة '{service}'", "service_stopped": "تمّ إيقاف خدمة '{service}'", @@ -71,10 +71,10 @@ "user_deleted": "تم حذف المستخدم", "user_deletion_failed": "لا يمكن حذف المستخدم", "user_unknown": "المستخدم {user} مجهول", - "user_update_failed": "لا يمكن تحديث المستخدم", - "user_updated": "تم تحديث المستخدم", + "user_update_failed": "لا يمكن تحديث المستخدم {user}: {error}", + "user_updated": "تم تحديث معلومات المستخدم", "yunohost_installing": "عملية تنصيب واي يونوهوست جارية …", - "yunohost_not_installed": "إنَّ واي يونوهوست ليس مُنَصَّب أو هو مثبت حاليا بشكل خاطئ. قم بتنفيذ الأمر 'yunohost tools postinstall'", + "yunohost_not_installed": "إنَّ واي يونوهوست ليس مُنَصَّب بشكل جيد. فضلًا قم بتنفيذ الأمر 'yunohost tools postinstall'", "migrations_list_conflict_pending_done": "لا يمكنك استخدام --previous و --done معًا على نفس سطر الأوامر.", "service_description_metronome": "يُدير حسابات الدردشة الفورية XMPP", "service_description_nginx": "يقوم بتوفير النفاذ و السماح بالوصول إلى كافة مواقع الويب المستضافة على خادومك", @@ -119,7 +119,7 @@ "already_up_to_date": "كل شيء على ما يرام. ليس هناك ما يتطلّب تحديثًا.", "service_description_slapd": "يخزّن المستخدمين والنطاقات والمعلومات المتعلقة بها", "service_reloaded": "تم إعادة تشغيل خدمة '{service}'", - "service_restarted": "تم إعادة تشغيل خدمة '{service}'", + "service_restarted": "تم إعادة تشغيل خدمة '{service}'", "group_unknown": "الفريق '{group}' مجهول", "group_deletion_failed": "فشلت عملية حذف الفريق '{group}': {error}", "group_deleted": "تم حذف الفريق '{group}'", @@ -170,7 +170,7 @@ "domain_config_cert_issuer": "الهيئة الموثِّقة", "domain_config_cert_renew": "تجديد شهادة Let's Encrypt", "domain_config_cert_summary": "حالة الشهادة", - "domain_config_cert_summary_ok": "حسنًا، يبدو أنّ الشهادة جيدة!", + "domain_config_cert_summary_ok": "حسنًا، يبدو أنّ الشهادة الحالية جيدة!", "domain_config_cert_validity": "مدة الصلاحية", "domain_config_xmpp": "المراسَلة الفورية (XMPP)", "global_settings_setting_root_password": "كلمة السر الجديدة لـ root", @@ -193,5 +193,68 @@ "diagnosis_ports_ok": "المنفذ {port} مفتوح ومتاح الوصول إليه مِن الخارج.", "global_settings_setting_smtp_allow_ipv6": "سماح IPv6", "disk_space_not_sufficient_update": "ليس هناك مساحة كافية لتحديث هذا التطبيق", - "domain_cert_gen_failed": "لا يمكن إعادة توليد الشهادة" -} + "domain_cert_gen_failed": "لا يمكن إعادة توليد الشهادة", + "diagnosis_apps_issue": "تم العثور على مشكلة في تطبيق {app}", + "tools_upgrade": "تحديث حُزم النظام", + "service_description_yunomdns": "يسمح لك بالوصول إلى خادمك الخاص باستخدام 'yunohost.local' في شبكتك المحلية", + "good_practices_about_user_password": "أنت الآن على وشك تحديد كلمة مرور مستخدم جديدة. يجب أن تتكون كلمة المرور من 8 أحرف على الأقل - أخذا بعين الإعتبار أنه من الممارسات الجيدة استخدام كلمة مرور أطول (أي عبارة مرور) و / أو مجموعة متنوعة من الأحرف (الأحرف الكبيرة والصغيرة والأرقام والأحرف الخاصة).", + "root_password_changed": "تم تغيير كلمة مرور الجذر", + "root_password_desynchronized": "تم تغيير كلمة مرور المدير ، لكن لم يتمكن YunoHost من نشرها على كلمة مرور الجذر!", + "user_import_bad_line": "سطر غير صحيح {line}: {details}", + "user_import_success": "تم استيراد المستخدمين بنجاح", + "visitors": "الزوار", + "password_too_simple_3": "يجب أن تتكون كلمة المرور من 8 أحرف على الأقل وأن تحتوي على أرقام و حروف كبيرة وصغيرة وأحرف خاصة", + "password_too_simple_4": "يجب أن تتكون كلمة المرور من 12 حرفًا على الأقل وأن تحتوي على أرقام وحروف كبيرة وصغيرة وأحرف خاصة", + "service_unknown": "الخدمة '{service}' غير معروفة", + "unbackup_app": "لن يتم حفظ التطبيق '{app}'", + "unrestore_app": "لن يتم استعادة التطبيق '{app}'", + "yunohost_already_installed": "إنّ YunoHost مُنصّب مِن قَبل", + "hook_name_unknown": "إسم الإجراء '{name}' غير معروف", + "app_manifest_install_ask_admin": "اختر مستخدمًا إداريًا لهذا التطبيق", + "domain_config_cert_summary_abouttoexpire": "مدة صلاحية الشهادة الحالية على وشك الإنتهاء ومِن المفتَرض أن يتم تجديدها تلقائيا قريبا.", + "app_manifest_install_ask_path": "اختر مسار URL (بعد النطاق) حيث ينبغي تنصيب هذا التطبيق", + "app_manifest_install_ask_domain": "اختر اسم النطاق الذي ينبغي فيه تنصيب هذا التطبيق", + "app_manifest_install_ask_is_public": "هل يجب أن يكون هذا التطبيق ظاهرًا للزوار المجهولين؟", + "domain_config_default_app_help": "سيعاد توجيه الناس تلقائيا إلى هذا التطبيق عند فتح اسم النطاق هذا. وإذا لم يُحدَّد أي تطبيق، يعاد توجيه الناس إلى استمارة تسجيل الدخولفي بوابة المستخدمين.", + "domain_config_xmpp_help": "ملاحطة: بعض ميزات الـ(إكس إم بي بي) ستتطلب أن تُحدّث سجلاتك الخاصة لـ DNS وتُعيد توليد شهادة Lets Encrypt لتفعيلها", + "certmanager_cert_install_failed": "أخفقت عملية تنصيب شهادة Let's Encrypt على {domains}", + "app_manifest_install_ask_password": "اختيار كلمة إدارية لهذا التطبيق", + "app_id_invalid": "مُعرّف التطبيق غير صالح", + "ask_admin_fullname": "الإسم الكامل للمدير", + "admins": "المدراء", + "all_users": "كافة مستخدمي واي يونوهوست", + "ask_user_domain": "اسم النطاق الذي سيُستخدَم لعنوان بريد المستخدِم وكذا لحساب XMPP", + "app_change_url_success": "تم تعديل الرابط التشعبي لتطبيق {app} إلى {domain}{path}", + "backup_app_failed": "لا يُمكن حِفظ {app}", + "pattern_password_app": "آسف، كلمات السر لا يمكن أن تحتوي على الحروف التالية: {forbidden_chars}", + "diagnosis_http_could_not_diagnose_details": "خطأ: {error}", + "mail_unavailable": "عنوان البريد الإلكتروني هذا مخصص لفريق المدراء", + "mailbox_disabled": "صندوق البريد معطل للمستخدم {user}", + "migration_0021_cleaning_up": "تنظيف ذاكرة التخزين المؤقت وكذا الحُزم التي تَعُد مفيدة…", + "migration_0021_yunohost_upgrade": "بداية تحديث نواة YunoHost…", + "migration_ldap_migration_failed_trying_to_rollback": "فشِلَت الهجرة… محاولة استعادة الرجوع إلى النظام.", + "migration_ldap_rollback_success": "تمت العودة إلى حالة النظام الأصلي.", + "migrations_success_forward": "اكتملت الهجرة {id}", + "password_too_simple_2": "يجب أن يكون طول كلمة المرور 8 حروف على الأقل وأن تحتوي على أرقام وحروف علوية ودنيا", + "pattern_lastname": "يجب أن يكون لقبًا صالحًا (على الأقل 3 حروف)", + "migration_0021_start": "بداية الهجرة إلى Bullseye", + "migrations_running_forward": "جارٍ تنفيذ الهجرة {id}…", + "password_confirmation_not_the_same": "كلمة المرور وتأكيدها غير متطابقان", + "password_too_long": "فضلا قم باختيار كلمة مرور طولها أقل مِن 127 حرفًا", + "pattern_fullname": "يجب أن يكون اسماً كاملاً صالحاً (على الأقل 3 حروف)", + "migration_0021_main_upgrade": "بداية التحديث الرئيسي…", + "migration_0021_patching_sources_list": "تحديث ملف sources.lists…", + "pattern_firstname": "يجب أن يكون اسماً أولياً صالحاً (على الأقل 3 حروف)", + "yunohost_configured": "تم إعداد YunoHost الآن", + "global_settings_setting_backup_compress_tar_archives": "ضغط النُسخ الاحتياطية", + "diagnosis_description_apps": "التطبيقات", + "danger": "خطر:", + "diagnosis_basesystem_hardware": "بنية الخادم هي {virt} {arch}", + "diagnosis_basesystem_hardware_model": "طراز الخادم {model}", + "diagnosis_mail_queue_ok": "هناك {nb_pending} رسائل بريد إلكتروني معلقة في قوائم انتظار البريد", + "diagnosis_mail_ehlo_ok": "يمكن الوصول إلى خادم بريد SMTP من الخارج وبالتالي فهو قادر على استقبال رسائل البريد الإلكتروني!", + "diagnosis_dns_good_conf": "تم إعداد سجلات نظام أسماء النطاقات DNS بشكل صحيح للنطاق {domain} (category {category})", + "diagnosis_ip_dnsresolution_working": "تحليل اسم النطاق يعمل!", + "diagnosis_mail_blacklist_listed_by": "إن عنوان الـ IP الخاص بك أو نطاقك {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 ' hinzufügen, dann als Hauptdomäne mit 'yunohost domain main-domain -n ' festlegen und dann können Sie die Domäne '{domain}' mit 'yunohost domain remove {domain}' entfernen'.'", "diagnosis_rootfstotalspace_critical": "Das Root-Filesystem hat noch freien Speicher von {space}. Das ist besorngiserregend! Der Speicher wird schnell aufgebraucht sein. 16 GB für das Root-Filesystem werden empfohlen.", "diagnosis_rootfstotalspace_warning": "Das Root-Filesystem hat noch freien Speicher von {space}. Möglich, dass das in Ordnung ist. Vielleicht ist er aber auch schneller aufgebraucht. 16 GB für das Root-Filesystem werden empfohlen.", - "log_remove_on_failed_restore": "Entfernen von '{}' nach einer fehlgeschlagenen Wiederherstellung aus einem Sicherungsarchiv", "log_backup_restore_app": "Wiederherstellen von '{}' aus einem Sicherungsarchiv", "log_backup_restore_system": "System aus einem Sicherungsarchiv wiederherstellen", "log_available_on_yunopaste": "Das Protokoll ist nun via {url} verfügbar", @@ -565,7 +564,6 @@ "diagnosis_apps_issue": "Ein Problem für die App {app} ist aufgetreten", "config_validate_time": "Sollte eine zulässige Zeit wie HH:MM sein", "config_validate_url": "Sollte eine zulässige web URL sein", - "config_version_not_supported": "Konfigurationspanel Versionen '{version}' sind nicht unterstützt.", "diagnosis_apps_allgood": "Alle installierten Apps berücksichtigen die grundlegenden Paketierungspraktiken", "diagnosis_apps_broken": "Diese App ist im YunoHost-Applikationskatalog momentan als defekt gekennzeichnet. Es könnte sich dabei um einen vorübergehendes Problem handeln. Während der/die Betreuer:in versucht das Problem zu beheben, ist die Upgrade-Funktion für diese App gesperrt.", "diagnosis_apps_not_in_app_catalog": "Diese Applikation steht nicht im Applikationskatalog von YunoHost. Sie sollten in Betracht ziehen, sie zu deinstallieren, weil sie keine Aktualisierungen mehr erhält und die Integrität und die Sicherheit Ihres Systems kompromittieren könnte.", @@ -605,7 +603,6 @@ "domain_dns_push_success": "DNS-Einträge aktualisiert!", "domain_dns_push_failed": "Die Aktualisierung der DNS-Einträge ist leider gescheitert.", "domain_dns_push_partial_failure": "DNS-Einträge teilweise aktualisiert: einige Warnungen/Fehler wurden gemeldet.", - "domain_config_features_disclaimer": "Bisher hat das Aktivieren/Deaktivieren von Mail- oder XMPP-Funktionen nur Auswirkungen auf die empfohlene und automatische DNS-Konfiguration, nicht auf die Systemkonfigurationen!", "domain_config_mail_in": "Eingehende E-Mails", "domain_config_mail_out": "Ausgehende E-Mails", "domain_config_xmpp": "Instant Messaging (XMPP)", @@ -695,5 +692,17 @@ "domain_config_cert_summary_abouttoexpire": "Das aktuelle Zertifikat läuft bald ab. Es sollte bald automatisch erneuert werden.", "domain_config_cert_summary_expired": "ACHTUNG: Das aktuelle Zertifikat ist nicht gültig! HTTPS wird gar nicht funktionieren!", "domain_config_cert_summary_letsencrypt": "Toll! Sie benutzen ein gültiges Let's Encrypt-Zertifikat!", - "domain_config_cert_summary_ok": "Gut, das aktuelle Zertifikat sieht gut aus!" -} + "domain_config_cert_summary_ok": "Gut, das aktuelle Zertifikat sieht gut aus!", + "app_change_url_require_full_domain": "{app} kann nicht auf diese neue URL verschoben werden, weil sie eine vollständige eigene Domäne benötigt (z.B. mit Pfad = /)", + "app_not_upgraded_broken_system_continue": "Die App '{failed_app}' konnte nicht aktualisiert werden und hat Ihr System in einen beschädigten Zustand versetzt (folglich wird --continue-on-failure ignoriert) und als Konsequenz wurde die Aktualisierung der folgenden Apps abgelehnt: {apps}", + "app_yunohost_version_not_supported": "Diese App setzt YunoHost >= {required} voraus aber die gegenwärtig installierte Version ist {current}", + "app_failed_to_upgrade_but_continue": "Die App {failed_app} konnte nicht aktualisiert werden und es wird anforderungsgemäss zur nächsten Aktualisierung fortgefahren. Starten sie 'yunohost log show {operation_logger_name}' um den Fehlerbericht zu sehen", + "app_not_upgraded_broken_system": "Die App '{failed_app}' konnte nicht aktualisiert werden und hat Ihr System in einen beschädigten Zustand versetzt und als Konzequenz wurde die Aktualisierung der folgenden Apps abgelehnt: {apps}", + "apps_failed_to_upgrade": "Diese Apps konnten nicht aktualisiert werden: {apps}", + "app_arch_not_supported": "Diese App kann nur auf bestimmten Architekturen {required} installiert werden, aber Ihre gegenwärtige Serverarchitektur ist {current}", + "app_not_enough_disk": "Diese App benötigt {required} freien Speicherplatz.", + "app_not_enough_ram": "Diese App benötigt {required} RAM um installiert/aktualisiert zu werden, aber es sind aktuell nur {current} verfügbar.", + "app_change_url_failed": "Kann die URL für {app} nicht ändern: {error}", + "app_change_url_script_failed": "Es ist ein Fehler im URL-Änderungs-Script aufgetreten", + "app_resource_failed": "Automatische Ressourcen-Allokation (provisioning), die Unterbindung des Zugriffts auf Ressourcen (deprovisioning) oder die Aktualisierung der Ressourcen für {app} schlug fehl: {error}" +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index 6065c75d8..83ee34052 100644 --- a/locales/en.json +++ b/locales/en.json @@ -13,21 +13,26 @@ "app_already_installed": "{app} is already installed", "app_already_installed_cant_change_url": "This app is already installed. The URL cannot be changed just by this function. Check in `app changeurl` if it's available.", "app_already_up_to_date": "{app} is already up-to-date", - "app_arch_not_supported": "This app can only be installed on architectures {', '.join(required)} but your server architecture is {current}", + "app_arch_not_supported": "This app can only be installed on architectures {required} but your server architecture is {current}", "app_argument_choice_invalid": "Pick a valid value for argument '{name}': '{value}' is not among the available choices ({choices})", "app_argument_invalid": "Pick a valid value for the argument '{name}': {error}", "app_argument_password_no_default": "Error while parsing password argument '{name}': password argument can't have a default value for security reasons", "app_argument_required": "Argument '{name}' is required", + "app_change_url_failed": "Could not change the url for {app}: {error}", "app_change_url_identical_domains": "The old and new domain/url_path are identical ('{domain}{path}'), nothing to do.", "app_change_url_no_script": "The app '{app_name}' doesn't support URL modification yet. Maybe you should upgrade it.", + "app_change_url_require_full_domain": "{app} cannot be moved to this new URL because it requires a full domain (i.e. with path = /)", + "app_change_url_script_failed": "An error occured inside the change url script", "app_change_url_success": "{app} URL is now {domain}{path}", "app_config_unable_to_apply": "Failed to apply config panel values.", "app_config_unable_to_read": "Failed to read config panel values.", + "app_corrupt_source": "YunoHost was able to download the asset '{source_id}' ({url}) for {app}, but the asset doesn't match the expected checksum. This could mean that some temporary network failure happened on your server, OR the asset was somehow changed by the upstream maintainer (or a malicious actor?) and YunoHost packagers need to investigate and update the app manifest to reflect this change.\n Expected sha256 checksum: {expected_sha256}\n Downloaded sha256 checksum: {computed_sha256}\n Downloaded file size: {size}", "app_extraction_failed": "Could not extract the installation files", + "app_failed_to_download_asset": "Failed to download asset '{source_id}' ({url}) for {app}: {out}", + "app_failed_to_upgrade_but_continue": "App {failed_app} failed to upgrade, continue to next upgrades as requested. Run 'yunohost log show {operation_logger_name}' to see failure log", "app_full_domain_unavailable": "Sorry, this app must be installed on a domain of its own, but other apps are already installed on the domain '{domain}'. You could use a subdomain dedicated to this app instead.", "app_id_invalid": "Invalid app ID", "app_install_failed": "Unable to install {app}: {error}", - "app_resource_failed": "Provisioning, deprovisioning, or updating resources for {app} failed: {error}", "app_install_files_invalid": "These files cannot be installed", "app_install_script_failed": "An error occurred inside the app installation script", "app_label_deprecated": "This command is deprecated! Please use the new command 'yunohost user permission update' to manage the app label.", @@ -46,10 +51,13 @@ "app_not_installed": "Could not find {app} in the list of installed apps: {all_apps}", "app_not_properly_removed": "{app} has not been properly removed", "app_not_upgraded": "The app '{failed_app}' failed to upgrade, and as a consequence the following apps' upgrades have been cancelled: {apps}", + "app_not_upgraded_broken_system": "The app '{failed_app}' failed to upgrade and put the system in a broken state, and as a consequence the following apps' upgrades have been cancelled: {apps}", + "app_not_upgraded_broken_system_continue": "The app '{failed_app}' failed to upgrade and put the system in a broken state (so --continue-on-failure is ignored), and as a consequence the following apps' upgrades have been cancelled: {apps}", "app_packaging_format_not_supported": "This app cannot be installed because its packaging format is not supported by your YunoHost version. You should probably consider upgrading your system.", "app_remove_after_failed_install": "Removing the app after installation failure...", "app_removed": "{app} uninstalled", "app_requirements_checking": "Checking requirements for {app}...", + "app_resource_failed": "Provisioning, deprovisioning, or updating resources for {app} failed: {error}", "app_restore_failed": "Could not restore {app}: {error}", "app_restore_script_failed": "An error occured inside the app restore script", "app_sources_fetch_failed": "Could not fetch source files, is the URL correct?", @@ -72,6 +80,8 @@ "apps_catalog_obsolete_cache": "The app catalog cache is empty or obsolete.", "apps_catalog_update_success": "The application catalog has been updated!", "apps_catalog_updating": "Updating application catalog...", + "apps_failed_to_upgrade": "Those applications failed to upgrade:{apps}", + "apps_failed_to_upgrade_line": "\n * {app_id} (to see corresponding log do a 'yunohost log show {operation_logger_name}')", "ask_admin_fullname": "Admin full name", "ask_admin_username": "Admin username", "ask_fullname": "Full name", @@ -103,14 +113,14 @@ "backup_copying_to_organize_the_archive": "Copying {size}MB to organize the archive", "backup_couldnt_bind": "Could not bind {src} to {dest}.", "backup_create_size_estimation": "The archive will contain about {size} of data.", - "backup_created": "Backup created", + "backup_created": "Backup created: {name}", "backup_creation_failed": "Could not create the backup archive", "backup_csv_addition_failed": "Could not add files to backup into the CSV file", "backup_csv_creation_failed": "Could not create the CSV file needed for restoration", "backup_custom_backup_error": "Custom backup method could not get past the 'backup' step", "backup_custom_mount_error": "Custom backup method could not get past the 'mount' step", "backup_delete_error": "Could not delete '{path}'", - "backup_deleted": "Backup deleted", + "backup_deleted": "Backup deleted: {name}", "backup_hook_unknown": "The backup hook '{hook}' is unknown", "backup_method_copy_finished": "Backup copy finalized", "backup_method_custom_finished": "Custom backup method '{method}' finished", @@ -163,7 +173,6 @@ "config_validate_email": "Should be a valid email", "config_validate_time": "Should be a valid time like HH:MM", "config_validate_url": "Should be a valid web URL", - "config_version_not_supported": "Config panel versions '{version}' are not supported.", "confirm_app_install_danger": "DANGER! This app is known to be still experimental (if not explicitly not working)! You should probably NOT install it unless you know what you are doing. NO SUPPORT will be provided if this app doesn't work or breaks your system... If you are willing to take that risk anyway, type '{answers}'", "confirm_app_install_thirdparty": "DANGER! This app is not part of YunoHost's app catalog. Installing third-party apps may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. NO SUPPORT will be provided if this app doesn't work or breaks your system... If you are willing to take that risk anyway, type '{answers}'", "confirm_app_install_warning": "Warning: This app may work, but is not well-integrated into YunoHost. Some features such as single sign-on and backup/restore might not be available. Install anyway? [{answers}] ", @@ -247,6 +256,7 @@ "diagnosis_ip_no_ipv4": "The server does not have working IPv4.", "diagnosis_ip_no_ipv6": "The server does not have working IPv6.", "diagnosis_ip_no_ipv6_tip": "Having a working IPv6 is not mandatory for your server to work, but it is better for the health of the Internet as a whole. IPv6 should usually be automatically configured by the system or your provider if it's available. Otherwise, you might need to configure a few things manually as explained in the documentation here: https://yunohost.org/#/ipv6. If you cannot enable IPv6 or if it seems too technical for you, you can also safely ignore this warning.", + "diagnosis_ip_no_ipv6_tip_important": "IPv6 should usually be automatically configured by the system or your provider if it's available. Otherwise, you might need to configure a few things manually as explained in the documentation here: https://yunohost.org/#/ipv6.", "diagnosis_ip_not_connected_at_all": "The server does not seem to be connected to the Internet at all!?", "diagnosis_ip_weird_resolvconf": "DNS resolution seems to be working, but it looks like you're using a custom /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 testing keyword from /etc/apt/sources.list.d/yunohost.list.", "disk_space_not_sufficient_install": "There is not enough disk space left to install this application", "disk_space_not_sufficient_update": "There is not enough disk space left to update this application", - "domain_cannot_add_xmpp_upload": "You cannot add domains starting with 'xmpp-upload.'. This kind of name is reserved for the XMPP upload feature integrated into YunoHost.", "domain_cannot_add_muc_upload": "You cannot add domains starting with 'muc.'. This kind of name is reserved for the XMPP multi-users chat feature integrated into YunoHost.", + "domain_cannot_add_xmpp_upload": "You cannot add domains starting with 'xmpp-upload.'. This kind of name is reserved for the XMPP upload feature integrated into YunoHost.", "domain_cannot_remove_main": "You cannot remove '{domain}' since it's the main domain, you first need to set another domain as the main domain using 'yunohost domain main-domain -n '; here is the list of candidate domains: {other_domains}", "domain_cannot_remove_main_add_new_one": "You cannot remove '{domain}' since it's the main domain and your only domain, you need to first add another domain using 'yunohost domain add ', then set is as the main domain using 'yunohost domain main-domain -n ' and then you can remove the domain '{domain}' using 'yunohost domain remove {domain}'.'", "domain_cert_gen_failed": "Could not generate certificate", @@ -346,9 +356,11 @@ "domain_config_cert_summary_selfsigned": "WARNING: Current certificate is self-signed. Browsers will display a spooky warning to new visitors!", "domain_config_cert_validity": "Validity", "domain_config_default_app": "Default app", + "domain_config_default_app_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.", "domain_config_mail_in": "Incoming emails", "domain_config_mail_out": "Outgoing emails", "domain_config_xmpp": "Instant messaging (XMPP)", + "domain_config_xmpp_help": "NB: some XMPP features will require that you update your DNS records and regenerate your Lets Encrypt certificate to be enabled", "domain_created": "Domain created", "domain_creation_failed": "Unable to create domain {domain}: {error}", "domain_deleted": "Domain deleted", @@ -408,17 +420,21 @@ "firewall_reloaded": "Firewall reloaded", "firewall_rules_cmd_failed": "Some firewall rule commands have failed. More info in log.", "global_settings_reset_success": "Reset global settings", - "global_settings_setting_passwordless_sudo": "Allow admins to use 'sudo' without re-typing their passwords", "global_settings_setting_admin_strength": "Admin password strength requirements", "global_settings_setting_admin_strength_help": "These requirements are only enforced when initializing or changing the password", "global_settings_setting_backup_compress_tar_archives": "Compress backups", "global_settings_setting_backup_compress_tar_archives_help": "When creating new backups, compress the archives (.tar.gz) instead of uncompressed archives (.tar). N.B. : enabling this option means create lighter backup archives, but the initial backup procedure will be significantly longer and heavy on CPU.", + "global_settings_setting_dns_exposure": "IP versions to consider for DNS configuration and diagnosis", + "global_settings_setting_dns_exposure_help": "NB: This only affects the recommended DNS configuration and diagnosis checks. This does not affect system configurations.", "global_settings_setting_nginx_compatibility": "NGINX Compatibility", "global_settings_setting_nginx_compatibility_help": "Compatibility vs. security tradeoff for the web server NGINX. Affects the ciphers (and other security-related aspects)", "global_settings_setting_nginx_redirect_to_https": "Force HTTPS", "global_settings_setting_nginx_redirect_to_https_help": "Redirect HTTP requests to HTTPs by default (DO NOT TURN OFF unless you really know what you're doing!)", + "global_settings_setting_passwordless_sudo": "Allow admins to use 'sudo' without re-typing their passwords", "global_settings_setting_pop3_enabled": "Enable POP3", "global_settings_setting_pop3_enabled_help": "Enable the POP3 protocol for the mail server", + "global_settings_setting_portal_theme": "Portal theme", + "global_settings_setting_portal_theme_help": "More info regarding creating custom portal themes at https://yunohost.org/theming", "global_settings_setting_postfix_compatibility": "Postfix Compatibility", "global_settings_setting_postfix_compatibility_help": "Compatibility vs. security tradeoff for the Postfix server. Affects the ciphers (and other security-related aspects)", "global_settings_setting_root_access_explain": "On Linux systems, 'root' is the absolute admin. In YunoHost context, direct 'root' SSH login is by default disable - except from the local network of the server. Members of the 'admins' group can use the sudo command to act as root from the command line. However, it can be helpful to have a (robust) root password to debug the system if for some reason regular admins can not login anymore.", @@ -440,8 +456,6 @@ "global_settings_setting_ssh_password_authentication_help": "Allow password authentication for SSH", "global_settings_setting_ssh_port": "SSH port", "global_settings_setting_ssowat_panel_overlay_enabled": "Enable the small 'YunoHost' portal shortcut square on apps", - "global_settings_setting_portal_theme": "Portal theme", - "global_settings_setting_portal_theme_help": "More info regarding creating custom portal themes at https://yunohost.org/theming", "global_settings_setting_user_strength": "User password strength requirements", "global_settings_setting_user_strength_help": "These requirements are only enforced when initializing or changing the password", "global_settings_setting_webadmin_allowlist": "Webadmin IP allowlist", @@ -461,11 +475,11 @@ "group_creation_failed": "Could not create the group '{group}': {error}", "group_deleted": "Group '{group}' deleted", "group_deletion_failed": "Could not delete the group '{group}': {error}", + "group_no_change": "Nothing to change for group '{group}'", "group_unknown": "The group '{group}' is unknown", + "group_update_aliases": "Updating aliases for group '{group}'", "group_update_failed": "Could not update the group '{group}': {error}", "group_updated": "Group '{group}' updated", - "group_update_aliases": "Updating aliases for group '{group}'", - "group_no_change": "Nothing to change for group '{group}'", "group_user_already_in_group": "User {user} is already in group {group}", "group_user_not_in_group": "User {user} is not in group {group}", "hook_exec_failed": "Could not run script: {path}", @@ -479,6 +493,7 @@ "invalid_number_max": "Must be lesser than {max}", "invalid_number_min": "Must be greater than {min}", "invalid_regex": "Invalid regex:'{regex}'", + "invalid_shell": "Invalid shell: {shell}", "ip6tables_unavailable": "You cannot play with ip6tables here. You are either in a container or your kernel does not support it", "iptables_unavailable": "You cannot play with iptables here. You are either in a container or your kernel does not support it", "ldap_attribute_already_exists": "LDAP attribute '{attribute}' already exists with value '{value}'", @@ -517,7 +532,6 @@ "log_permission_url": "Update URL related to permission '{}'", "log_regen_conf": "Regenerate system configurations '{}'", "log_remove_on_failed_install": "Remove '{}' after a failed installation", - "log_remove_on_failed_restore": "Remove '{}' after a failed restore from a backup archive", "log_resource_snippet": "Provisioning/deprovisioning/updating a resource", "log_selfsigned_cert_install": "Install self-signed certificate on '{}' domain", "log_settings_reset": "Reset setting", @@ -758,4 +772,4 @@ "yunohost_installing": "Installing YunoHost...", "yunohost_not_installed": "YunoHost is not correctly installed. Please run 'yunohost tools postinstall'", "yunohost_postinstall_end_tip": "The post-install completed! To finalize your setup, please consider:\n - diagnose potential issues through the 'Diagnosis' section of the webadmin (or 'yunohost diagnosis run' in command-line);\n - reading the 'Finalizing your setup' and 'Getting to know YunoHost' parts in the admin documentation: https://yunohost.org/admindoc." -} +} \ No newline at end of file diff --git a/locales/eo.json b/locales/eo.json index 13c96499b..b0bdf280b 100644 --- a/locales/eo.json +++ b/locales/eo.json @@ -191,7 +191,6 @@ "unexpected_error": "Io neatendita iris malbone: {error}", "password_listed": "Ĉi tiu pasvorto estas inter la plej uzataj pasvortoj en la mondo. Bonvolu elekti ion pli unikan.", "ssowat_conf_generated": "SSOwat-agordo generita", - "log_remove_on_failed_restore": "Forigu '{}' post malsukcesa restarigo de rezerva ar archiveivo", "dpkg_is_broken": "Vi ne povas fari ĉi tion nun ĉar dpkg/APT (la administrantoj pri pakaĵaj sistemoj) ŝajnas esti rompita stato ... Vi povas provi solvi ĉi tiun problemon per konekto per SSH kaj funkcianta `sudo dpkg --configure -a`.", "certmanager_cert_signing_failed": "Ne povis subskribi la novan atestilon", "log_tools_upgrade": "Ĝisdatigu sistemajn pakaĵojn", diff --git a/locales/es.json b/locales/es.json index 8637c3da8..85d7b1f43 100644 --- a/locales/es.json +++ b/locales/es.json @@ -78,8 +78,8 @@ "pattern_backup_archive_name": "Debe ser un nombre de archivo válido con un máximo de 30 caracteres, solo se admiten caracteres alfanuméricos y los caracteres -_. (guiones y punto)", "pattern_domain": "El nombre de dominio debe ser válido (por ejemplo mi-dominio.org)", "pattern_email": "Debe ser una dirección de correo electrónico válida, sin el símbolo '+' (ej. alguien@ejemplo.com)", - "pattern_firstname": "Debe ser un nombre válido", - "pattern_lastname": "Debe ser un apellido válido", + "pattern_firstname": "Debe ser un nombre válido (al menos 3 caracteres)", + "pattern_lastname": "Debe ser un apellido válido (al menos 3 caracteres)", "pattern_mailbox_quota": "Debe ser un tamaño con el sufijo «b/k/M/G/T» o «0» para no tener una cuota", "pattern_password": "Debe contener al menos 3 caracteres", "pattern_port_or_range": "Debe ser un número de puerto válido (es decir entre 0-65535) o un intervalo de puertos (por ejemplo 100:200)", @@ -266,7 +266,7 @@ "migrations_failed_to_load_migration": "No se pudo cargar la migración {id}: {error}", "migrations_dependencies_not_satisfied": "Ejecutar estas migraciones: «{dependencies_id}» antes de migrar {id}.", "migrations_already_ran": "Esas migraciones ya se han realizado: {ids}", - "mail_unavailable": "Esta dirección de correo está reservada y será asignada automáticamente al primer usuario", + "mail_unavailable": "Esta dirección de correo electrónico está reservada para el grupo de administradores", "mailbox_disabled": "Correo desactivado para usuario {user}", "log_tools_reboot": "Reiniciar el servidor", "log_tools_shutdown": "Apagar el servidor", @@ -287,7 +287,6 @@ "log_domain_remove": "Eliminar el dominio «{}» de la configuración del sistema", "log_domain_add": "Añadir el dominio «{}» a la configuración del sistema", "log_remove_on_failed_install": "Eliminar «{}» después de una instalación fallida", - "log_remove_on_failed_restore": "Eliminar «{}» después de una restauración fallida desde un archivo de respaldo", "log_backup_restore_app": "Restaurar «{}» desde un archivo de respaldo", "log_backup_restore_system": "Restaurar sistema desde un archivo de respaldo", "log_available_on_yunopaste": "Este registro está ahora disponible vía {url}", @@ -316,7 +315,7 @@ "dyndns_could_not_check_available": "No se pudo comprobar si {domain} está disponible en {provider}.", "domain_dns_conf_is_just_a_recommendation": "Este comando muestra la configuración *recomendada*. No configura las entradas DNS por ti. Es tu responsabilidad configurar la zona DNS en su registrador según esta recomendación.", "dpkg_lock_not_available": "Esta orden no se puede ejecutar en este momento ,parece que programa está usando el bloqueo de dpkg (el gestor de paquetes del sistema)", - "dpkg_is_broken": "No puede hacer esto en este momento porque dpkg/APT (los gestores de paquetes del sistema) parecen estar mal configurados... Puede tratar de solucionar este problema conectando a través de SSH y ejecutando `sudo apt install --fix-broken` y/o `sudo dpkg --configure -a`.", + "dpkg_is_broken": "No puede hacer esto en este momento porque dpkg/APT (los gestores de paquetes del sistema) parecen estar mal configurados... Puede tratar de solucionar este problema conectando a través de SSH y ejecutando `sudo apt install --fix-broken` y/o `sudo dpkg --configure -a` y/o `sudo dpkg --audit`.", "confirm_app_install_thirdparty": "¡PELIGRO! Esta aplicación no forma parte del catálogo de aplicaciones de YunoHost. La instalación de aplicaciones de terceros puede comprometer la integridad y seguridad de tu sistema. Probablemente NO deberías instalarla a menos que sepas lo que estás haciendo. NO se proporcionará NINGÚN SOPORTE si esta aplicación no funciona o rompe su sistema… Si de todos modos quieres correr ese riesgo, escribe '{answers}'", "confirm_app_install_danger": "¡PELIGRO! ¡Esta aplicación sigue siendo experimental (si no es expresamente no funcional)! Probablemente NO deberías instalarla a menos que sepas lo que estás haciendo. NO se proporcionará NINGÚN SOPORTE si esta aplicación no funciona o rompe tu sistema… Si de todos modos quieres correr ese riesgo, escribe '{answers}'", "confirm_app_install_warning": "Aviso: esta aplicación puede funcionar pero no está bien integrada en YunoHost. Algunas herramientas como la autentificación única y respaldo/restauración podrían no estar disponibles. ¿Instalar de todos modos? [{answers}] ", @@ -454,7 +453,7 @@ "diagnosis_ports_forwarding_tip": "Para solucionar este incidente, lo más seguro deberías configurar la redirección de los puertos en el router como se especifica en https://yunohost.org/isp_box_config", "certmanager_warning_subdomain_dns_record": "El subdominio '{subdomain}' no se resuelve en la misma dirección IP que '{domain}'. Algunas funciones no estarán disponibles hasta que solucione esto y regenere el certificado.", "domain_cannot_add_xmpp_upload": "No puede agregar dominios que comiencen con 'xmpp-upload'. Este tipo de nombre está reservado para la función de carga XMPP integrada en YunoHost.", - "yunohost_postinstall_end_tip": "¡La post-instalación completada! Para finalizar su configuración, por favor considere:\n - agregar un primer usuario a través de la sección 'Usuarios' del administrador web (o 'yunohost user create ' en la línea de comandos);\n - diagnosticar problemas potenciales a través de la sección 'Diagnóstico' del administrador web (o 'yunohost diagnosis run' en la línea de comandos);\n - leyendo las partes 'Finalizando su configuración' y 'Conociendo YunoHost' en la documentación del administrador: https://yunohost.org/admindoc.", + "yunohost_postinstall_end_tip": "¡La post-instalación completada! Para finalizar su configuración, por favor considere:\n - diagnosticar problemas potenciales a través de la sección 'Diagnóstico' del administrador web (o 'yunohost diagnosis run' en la línea de comandos);\n - leyendo las partes 'Finalizando su configuración' y 'Conociendo YunoHost' en la documentación del administrador: https://yunohost.org/admindoc.", "diagnosis_dns_point_to_doc": "Por favor, consulta la documentación en https://yunohost.org/dns_config si necesitas ayuda para configurar los registros DNS.", "diagnosis_ip_global": "IP Global: {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": "apt (el gestor de paquetes del sistema) está configurado actualmente para instalar paquetes de nombre en clave 'estable', en lugar del nombre en clave de la versión actual de Debian (bullseye).", - "diagnosis_using_stable_codename_details": "Esto suele deberse a una configuración incorrecta de su proveedor de alojamiento. Esto es peligroso, porque tan pronto como la siguiente versión de Debian se convierta en la nueva 'estable', apt querrá actualizar todos los paquetes del sistema sin pasar por un procedimiento de migración adecuado. Se recomienda arreglar esto editando la fuente de apt para el repositorio base de Debian, y reemplazar la palabra clave stable por bullseye. El fichero de configuración correspondiente debería ser /etc/apt/sources.list, o un fichero en /etc/apt/sources.list.d/." -} + "diagnosis_using_stable_codename_details": "Esto suele deberse a una configuración incorrecta de su proveedor de alojamiento. Esto es peligroso, porque tan pronto como la siguiente versión de Debian se convierta en la nueva 'estable', apt querrá actualizar todos los paquetes del sistema sin pasar por un procedimiento de migración adecuado. Se recomienda arreglar esto editando la fuente de apt para el repositorio base de Debian, y reemplazar la palabra clave stable por bullseye. El fichero de configuración correspondiente debería ser /etc/apt/sources.list, o un fichero en /etc/apt/sources.list.d/.", + "domain_config_cert_install": "Instalar el certificado Let's Encrypt", + "domain_cannot_add_muc_upload": "No puedes añadir dominios que empiecen por 'muc.'. Este tipo de nombre está reservado para la función de chat XMPP multi-usuarios integrada en YunoHost.", + "domain_config_cert_renew_help": "El certificado se renovará automáticamente durante los últimos 15 días de validez. Si lo desea, puede renovarlo manualmente. (No recomendado).", + "domain_config_cert_summary_expired": "CRÍTICO: ¡El certificado actual no es válido! ¡HTTPS no funcionará en absoluto!", + "domain_config_cert_summary_letsencrypt": "¡Muy bien! Estás utilizando un certificado Let's Encrypt válido.", + "global_settings_setting_postfix_compatibility": "Compatibilidad con Postfix", + "global_settings_setting_root_password_confirm": "Nueva contraseña de root (confirmar)", + "global_settings_setting_webadmin_allowlist_enabled": "Activar la lista de IPs permitidas para Webadmin", + "migration_0024_rebuild_python_venv_broken_app": "Omitiendo {app} porque virtualenv no puede ser reconstruido fácilmente para esta app. En su lugar, deberías arreglar la situación forzando la actualización de esta app usando `yunohost app upgrade --force {app}`.", + "migration_0024_rebuild_python_venv_in_progress": "Ahora intentando reconstruir el virtualenv de Python para `{app}`", + "confirm_app_insufficient_ram": "¡PELIGRO! Esta aplicación requiere {required} de RAM para ser instalada/actualizada, pero solo hay {current} disponible actualmente. Incluso si esta aplicación pudiera ejecutarse, su proceso de instalación/actualización requiere una gran cantidad de RAM, por lo que tu servidor puede congelarse y fallar miserablemente. Si estás dispuesto a asumir ese riesgo de todos modos, escribe '{answers}'", + "confirm_notifications_read": "ADVERTENCIA: Deberías revisar las notificaciones de la aplicación antes de continuar, puede haber información importante que debes conocer. [{answers}]", + "domain_config_cert_summary_selfsigned": "ADVERTENCIA: El certificado actual es autofirmado. ¡Los navegadores mostrarán una espeluznante advertencia a los nuevos visitantes!.", + "global_settings_setting_backup_compress_tar_archives": "Comprimir las copias de seguridad", + "global_settings_setting_root_access_explain": "En sistemas Linux, 'root' es el administrador absoluto. En el contexto de YunoHost, el acceso directo 'root' SSH está deshabilitado por defecto - excepto desde la red local del servidor. Los miembros del grupo 'admins' pueden usar el comando sudo para actuar como root desde la linea de comandos. Sin embargo, puede ser útil tener una contraseña de root (robusta) para depurar el sistema si por alguna razón los administradores regulares ya no pueden iniciar sesión.", + "migration_0021_not_buster2": "¡La distribución Debian actual no es Buster! Si ya ha ejecutado la migración Buster->Bullseye, entonces este error es sintomático del hecho de que el procedimiento de migración no fue 100% exitoso (de lo contrario YunoHost lo habría marcado como completado). Se recomienda investigar lo sucedido con el equipo de soporte, que necesitará el registro **completo** de la `migración, que se puede encontrar en Herramientas > Registros en el webadmin.", + "global_settings_reset_success": "Restablecer la configuración global", + "global_settings_setting_nginx_compatibility": "Compatibilidad con NGINX", + "global_settings_setting_nginx_redirect_to_https": "Forzar HTTPS", + "global_settings_setting_user_strength_help": "Estos requisitos sólo se aplican al inicializar o cambiar la contraseña", + "log_resource_snippet": "Aprovisionar/desaprovisionar/actualizar un recurso", + "global_settings_setting_pop3_enabled": "Habilitar POP3", + "global_settings_setting_smtp_allow_ipv6": "Permitir IPv6", + "global_settings_setting_security_experimental_enabled": "Funciones de seguridad experimentales", + "migration_0024_rebuild_python_venv_disclaimer_ignored": "Virtualenvs no puede reconstruirse automáticamente para esas aplicaciones. Necesitas forzar una actualización para ellas, lo que puede hacerse desde la línea de comandos con: `yunohost app upgrade --force APP`: {ignored_apps}", + "migration_0024_rebuild_python_venv_failed": "Error al reconstruir el virtualenv de Python para {app}. La aplicación puede no funcionar mientras esto no se resuelva. Deberías arreglar la situación forzando la actualización de esta app usando `yunohost app upgrade --force {app}`.", + "app_arch_not_supported": "Esta aplicación sólo puede instalarse en arquitecturas {required} pero la arquitectura de su servidor es {current}", + "app_resource_failed": "Falló la asignación, desasignación o actualización de recursos para {app}: {error}", + "app_not_enough_disk": "Esta aplicación requiere {required} espacio libre.", + "app_not_enough_ram": "Esta aplicación requiere {required} de RAM para ser instalada/actualizada, pero solo hay {current} disponible actualmente.", + "app_yunohost_version_not_supported": "Esta aplicación requiere YunoHost >= {required} pero la versión actualmente instalada es {current}", + "global_settings_setting_ssh_compatibility": "Compatibilidad con SSH", + "root_password_changed": "la contraseña de root fue cambiada", + "domain_config_acme_eligible_explain": "Este dominio no parece estar preparado para un certificado Let's Encrypt. Compruebe la configuración DNS y la accesibilidad del servidor HTTP. Las secciones \"Registros DNS\" y \"Web\" de la página de diagnóstico pueden ayudarte a entender qué está mal configurado.", + "domain_config_cert_no_checks": "Ignorar las comprobaciones de diagnóstico", + "domain_config_cert_renew": "Renovar el certificado Let's Encrypt", + "domain_config_cert_summary": "Estado del certificado", + "domain_config_cert_summary_abouttoexpire": "El certificado actual está a punto de caducar. Pronto debería renovarse automáticamente.", + "domain_config_cert_summary_ok": "Muy bien, ¡el certificado actual tiene buena pinta!", + "domain_config_cert_validity": "Validez", + "global_settings_setting_admin_strength_help": "Estos requisitos sólo se aplican al inicializar o cambiar la contraseña", + "global_settings_setting_pop3_enabled_help": "Habilitar el protocolo POP3 para el servidor de correo", + "log_settings_reset_all": "Restablecer todos los ajustes", + "log_settings_set": "Aplicar ajustes", + "pattern_fullname": "Debe ser un nombre completo válido (al menos 3 caracteres)", + "password_confirmation_not_the_same": "La contraseña y su confirmación no coinciden", + "password_too_long": "Elija una contraseña de menos de 127 caracteres", + "diagnosis_using_yunohost_testing": "apt (el gestor de paquetes del sistema) está actualmente configurado para instalar cualquier actualización de 'testing' para el núcleo de YunoHost.", + "diagnosis_using_yunohost_testing_details": "Esto probablemente esté bien si sabes lo que estás haciendo, ¡pero presta atención a las notas de la versión antes de instalar actualizaciones de YunoHost! Si quieres deshabilitar las actualizaciones de prueba, debes eliminar la palabra clave testing de /etc/apt/sources.list.d/yunohost.list.", + "global_settings_setting_passwordless_sudo": "Permitir a los administradores utilizar 'sudo' sin tener que volver a escribir sus contraseñas.", + "group_update_aliases": "Actualizando alias para el grupo '{group}'", + "group_no_change": "Nada que cambiar para el grupo '{group}'", + "global_settings_setting_portal_theme": "Tema del portal", + "global_settings_setting_portal_theme_help": "Más información sobre la creación de temas de portal personalizados en https://yunohost.org/theming", + "invalid_credentials": "Contraseña o nombre de usuario no válidos", + "global_settings_setting_root_password": "Nueva contraseña de root", + "global_settings_setting_webadmin_allowlist": "Lista de IPs permitidas para Webadmin", + "migration_0024_rebuild_python_venv_disclaimer_base": "Tras la actualización a Debian Bullseye, algunas aplicaciones Python necesitan ser parcialmente reconstruidas para ser convertidas a la nueva versión de Python distribuida en Debian (en términos técnicos: lo que se llama el 'virtualenv' necesita ser recreado). Mientras tanto, esas aplicaciones Python pueden no funcionar. YunoHost puede intentar reconstruir el virtualenv para algunas de ellas, como se detalla a continuación. Para otras aplicaciones, o si el intento de reconstrucción falla, necesitarás forzar manualmente una actualización para esas aplicaciones.", + "migration_description_0024_rebuild_python_venv": "Reparar la aplicación Python tras la migración a bullseye", + "global_settings_setting_smtp_relay_enabled": "Activar el relé SMTP", + "domain_config_acme_eligible": "Elegibilidad ACME", + "global_settings_setting_ssh_password_authentication": "Autenticación por contraseña", + "domain_config_cert_issuer": "Autoridad de certificación", + "invalid_shell": "Shell inválido: {shell}", + "log_settings_reset": "Restablecer ajuste", + "migration_description_0026_new_admins_group": "Migrar al nuevo sistema de 'varios administradores'", + "visitors": "Visitantes", + "global_settings_setting_smtp_relay_host": "Host de retransmisión SMTP", + "migration_0024_rebuild_python_venv_disclaimer_rebuild": "Se intentará reconstruir el virtualenv para las siguientes apps (NB: ¡la operación puede llevar algún tiempo!): {rebuild_apps}", + "migration_description_0025_global_settings_to_configpanel": "Migración de la nomenclatura de ajustes globales heredada a la nomenclatura nueva y moderna", + "registrar_infos": "Información sobre el registrador" +} \ No newline at end of file diff --git a/locales/eu.json b/locales/eu.json index d58289bf4..4d425789e 100644 --- a/locales/eu.json +++ b/locales/eu.json @@ -8,7 +8,7 @@ "diagnosis_ip_global": "IP orokorra: {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: {cmd_to_fix}", "log_help_to_get_log": "'{desc}' eragiketaren erregistroa ikusteko, exekutatu 'yunohost log show {name}'", "dpkg_is_broken": "Une honetan ezinezkoa da sistemaren dpkg/APT pakateen kudeatzaileek hondatutako itxura dutelako… Arazoa konpontzeko SSH bidez konektatzen saia zaitezke eta ondoren exekutatu 'sudo apt install --fix-broken' edota 'sudo dpkg --configure -a' edota 'sudo dpkg --audit'.", @@ -737,5 +734,26 @@ "password_too_long": "Aukeratu 127 karaktere baino laburragoa den pasahitz bat", "diagnosis_using_stable_codename_details": "Ostatatzaileak zerbait oker ezarri duenean gertatu ohi da hau. Arriskutsua da, Debianen datorren bertsioa 'estable' (egonkorra) bilakatzen denean, apt-ek sistemaren pakete guztiak bertsio-berritzen saiatuko da, beharrezko migrazio-prozedurarik burutu gabe. Debianen repositorioan apt iturria editatzen konpontzea da gomendioa, stable gakoa bullseye gakoarekin ordezkatuz. Ezarpen-fitxategia /etc/apt/sources.list izan beharko litzateke, edo /etc/apt/sources.list.d/ direktorioko fitxategiren bat.", "group_update_aliases": "'{group}' taldearen aliasak eguneratzen", - "group_no_change": "Ez da ezer aldatu behar '{group}' talderako" + "group_no_change": "Ez da ezer aldatu behar '{group}' talderako", + "app_not_enough_ram": "Aplikazio honek {required} RAM behar ditu instalatu edo bertsio-berritzeko, baina {current} bakarrik daude erabilgarri une honetan.", + "domain_cannot_add_muc_upload": "Ezin duzu 'muc.'-ekin hasten den domeinurik gehitu. Mota honetako izenak YunoHosten integratuta dagoen XMPP taldeko txatek erabil ditzaten gordeta daude.", + "confirm_app_insufficient_ram": "KONTUZ! Aplikazio honek {required} RAM behar ditu instalatu edo bertsio-berritzeko baina unean soilik {current} daude erabilgarri. Aplikazioa ibiliko balitz ere, instalazioak edo bertsio-berritzeak RAM kopuru handia behar du eta zure zerbitzariak erantzuteari utzi eta huts egin lezake. Hala ere arriskatu nahi baduzu idatzi '{answers}'", + "confirm_notifications_read": "ADI: ikuskatu aplikazioaren jakinarazpenak jarraitu baino lehen, baliteke jakin beharreko zerbait esatea. [{answers}]", + "app_arch_not_supported": "Aplikazio hau {required} arkitekturan instala daiteke bakarrik, baina zure zerbitzariaren arkitektura {current} da", + "app_resource_failed": "Huts egin du {app} aplikaziorako baliabideen eguneraketak / prestaketak / askapenak: {error}", + "app_not_enough_disk": "Aplikazio honek {required} espazio libre behar ditu.", + "app_yunohost_version_not_supported": "Aplikazio honek YunoHost >= {required} behar du baina unean instalatutako bertsioa {current} da", + "global_settings_setting_passwordless_sudo": "Baimendu administrariek 'sudo' erabiltzea pasahitzak berriro idatzi beharrik gabe", + "global_settings_setting_portal_theme": "Atariko gaia", + "global_settings_setting_portal_theme_help": "Atariko gai propioak sortzeari buruzko informazio gehiago: https://yunohost.org/theming", + "invalid_shell": "Shell baliogabea: {shell}", + "domain_config_default_app_help": "Jendea automatikoki birbideratuko da aplikazio honetara domeinu hau bisitatzerakoan. Aplikaziorik ezarri ezean, jendea saioa hasteko erabiltzaileen atarira birbideratuko da.", + "domain_config_xmpp_help": "Ohart ongi: XMPP ezaugarri batzuk gaitzeko DNS erregistroak eguneratu eta Lets Encrypt ziurtagiria birsortu beharko dira", + "global_settings_setting_dns_exposure": "DNS ezarpenetan eta diagnostikoan kontuan hartzeko IP bertsioak", + "global_settings_setting_dns_exposure_help": "Ohart ongi: honek gomendatutako DNS ezarpenei eta diagnostikoari eragiten die soilik. Ez du eraginik sistemaren ezarpenetan.", + "diagnosis_ip_no_ipv6_tip_important": "IPv6 automatikoki ezarri ohi du sistemak edo hornitzaileak erabilgarri baldin badago. Bestela eskuz ezarri beharko dituzu aukera batzuk ondorengo dokumentazioan azaldu bezala: https://yunohost.org/#/ipv6.", + "pattern_fullname": "Baliozko izen oso bat izan behar da (gutxienez hiru karaktere)", + "app_change_url_failed": "Ezin izan da {app} aplikazioaren URLa aldatu: {error}", + "app_change_url_require_full_domain": "Ezin da {app} aplikazioa URL berri honetara aldatu domeinu oso bat behar duelako (i.e. with path = /)", + "app_change_url_script_failed": "Errorea gertatu da URLa aldatzeko aginduaren barnean" } diff --git a/locales/fa.json b/locales/fa.json index 92e05bdad..fe6310c5d 100644 --- a/locales/fa.json +++ b/locales/fa.json @@ -414,7 +414,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": "بایگانی پشتیبان ایجاد کنید", diff --git a/locales/fr.json b/locales/fr.json index 959ef1a8d..9411fec96 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -27,10 +27,10 @@ "backup_archive_name_unknown": "L'archive locale de sauvegarde nommée '{name}' est inconnue", "backup_archive_open_failed": "Impossible d'ouvrir l'archive de la sauvegarde", "backup_cleaning_failed": "Impossible de nettoyer le dossier temporaire de sauvegarde", - "backup_created": "Sauvegarde terminée", + "backup_created": "Sauvegarde créée : {name}", "backup_creation_failed": "Impossible de créer l'archive de la sauvegarde", "backup_delete_error": "Impossible de supprimer '{path}'", - "backup_deleted": "La sauvegarde a été supprimée", + "backup_deleted": "Sauvegarde supprimée : {name}", "backup_hook_unknown": "Script de sauvegarde '{hook}' inconnu", "backup_nothings_done": "Il n'y a rien à sauvegarder", "backup_output_directory_forbidden": "Choisissez un répertoire de destination différent. Les sauvegardes ne peuvent pas être créées dans les sous-dossiers /bin, /boot, /dev, /etc, /lib, /root, /run, /sbin, /sys, /usr, /var ou /home/yunohost.backup/archives", @@ -241,7 +241,6 @@ "log_available_on_yunopaste": "Le journal est désormais disponible via {url}", "log_backup_restore_system": "Restaurer le système depuis une archive de sauvegarde", "log_backup_restore_app": "Restaurer '{}' depuis une sauvegarde", - "log_remove_on_failed_restore": "Retirer '{}' après un échec de restauration depuis une archive de sauvegarde", "log_remove_on_failed_install": "Enlever '{}' après une installation échouée", "log_domain_add": "Ajouter le domaine '{}' dans la configuration du système", "log_domain_remove": "Enlever le domaine '{}' de la configuration du système", @@ -259,7 +258,7 @@ "log_tools_upgrade": "Mettre à jour les paquets du système", "log_tools_shutdown": "Éteindre votre serveur", "log_tools_reboot": "Redémarrer votre serveur", - "mail_unavailable": "Cette adresse e-mail est réservée au groupe des administrateurs", + "mail_unavailable": "Cette adresse email est réservée au groupe des administrateurs", "good_practices_about_admin_password": "Vous êtes sur le point de définir un nouveau mot de passe administrateur. Le mot de passe doit comporter au moins 8 caractères, bien qu'il soit recommandé d'utiliser un mot de passe plus long (c'est-à-dire une phrase secrète) et/ou d'utiliser une combinaison de caractères (majuscules, minuscules, chiffres et caractères spéciaux).", "good_practices_about_user_password": "Vous êtes sur le point de définir un nouveau mot de passe utilisateur. Le mot de passe doit comporter au moins 8 caractères, bien qu'il soit recommandé d'utiliser un mot de passe plus long (c'est-à-dire une phrase secrète) et/ou une combinaison de caractères (majuscules, minuscules, chiffres et caractères spéciaux).", "password_listed": "Ce mot de passe fait partie des mots de passe les plus utilisés dans le monde. Veuillez en choisir un autre moins commun et plus robuste.", @@ -389,7 +388,7 @@ "diagnosis_basesystem_ynh_single_version": "{package} version : {version} ({repo})", "diagnosis_basesystem_ynh_main_version": "Le serveur utilise YunoHost {main_version} ({repo})", "diagnosis_basesystem_ynh_inconsistent_versions": "Vous exécutez des versions incohérentes des packages YunoHost ... très probablement en raison d'une mise à niveau échouée ou partielle.", - "diagnosis_failed_for_category": "Échec du diagnostic pour la catégorie '{category}' : {error}", + "diagnosis_failed_for_category": "Échec du diagnostic pour la catégorie '{category}' : {error}", "diagnosis_cache_still_valid": "(Le cache est encore valide pour le diagnostic {category}. Il ne sera pas re-diagnostiqué pour le moment !)", "diagnosis_ignored_issues": "(+ {nb_ignored} problème(s) ignoré(s))", "diagnosis_found_warnings": "Trouvé {warnings} objet(s) pouvant être amélioré(s) pour {category}.", @@ -483,7 +482,7 @@ "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "Certains opérateurs ne vous laisseront pas débloquer le port 25 parce qu'ils ne se soucient pas de la neutralité du Net.
- Certains d'entre eux offrent la possibilité d'utiliser un serveur de messagerie relai bien que cela implique que celui-ci sera en mesure d'espionner le trafic de votre messagerie.
- Une alternative respectueuse de la vie privée consiste à utiliser un VPN *avec une IP publique dédiée* pour contourner ce type de limites. Voir https://yunohost.org/#/vpn_advantage
- Vous pouvez également envisager de passer à un fournisseur plus respectueux de la neutralité du net", "diagnosis_mail_ehlo_ok": "Le serveur de messagerie SMTP est accessible de l'extérieur et peut donc recevoir des emails !", "diagnosis_mail_ehlo_unreachable": "Le serveur de messagerie SMTP est inaccessible de l'extérieur en IPv{ipversion}. Il ne pourra pas recevoir des emails.", - "diagnosis_mail_ehlo_unreachable_details": "Impossible d'ouvrir une connexion sur le port 25 à votre serveur en IPv{ipversion}. Il semble inaccessible.
1. La cause la plus courante de ce problème est que le port 25 n'est pas correctement redirigé vers votre serveur.
2. Vous devez également vous assurer que le service postfix est en cours d'exécution.
3. Sur les configurations plus complexes : assurez-vous qu'aucun pare-feu ou proxy inversé n'interfère.", + "diagnosis_mail_ehlo_unreachable_details": "Impossible d'ouvrir une connexion sur le port 25 à votre serveur en IPv{ipversion}. Il semble inaccessible.
1. La cause la plus courante de ce problème est que le port 25 n'est pas correctement redirigé vers votre serveur.
2. Vous devez également vous assurer que le service postfix est en cours d'exécution.
3. Sur les configurations plus complexes : assurez-vous qu'aucun pare-feu ou proxy inversé n'interfère.", "diagnosis_mail_ehlo_wrong_details": "Le EHLO reçu par le serveur de diagnostique distant en IPv{ipversion} est différent du domaine de votre serveur.
EHLO reçu : {wrong_ehlo}
Attendu : {right_ehlo}
La cause la plus courante à ce problème est que le port 25 n'est pas correctement redirigé vers votre serveur. Vous pouvez également vous assurer qu'aucun pare-feu ou reverse-proxy n'interfère.", "diagnosis_mail_fcrdns_nok_alternatives_4": "Certains opérateurs ne vous laisseront pas configurer votre reverse-DNS (ou leur fonctionnalité pourrait être cassée ...). Si vous rencontrez des problèmes à cause de cela, envisagez les solutions suivantes :
- Certains FAI offre cette possibilité à l'aide d'un relais de serveur de messagerie bien que cela implique que le relais pourra espionner votre trafic de messagerie.
- Une alternative respectueuse de la vie privée consiste à utiliser un VPN *avec une IP publique dédiée* pour contourner ce type de limites. Voir https://yunohost.org/#/vpn_advantage
- Enfin, il est également possible de changer d'opérateur", "diagnosis_mail_fcrdns_nok_alternatives_6": "Certains fournisseurs ne vous laisseront pas configurer votre DNS inversé (ou leur fonctionnalité pourrait être cassée...). Si votre DNS inversé est correctement configuré en IPv4, vous pouvez essayer de désactiver l'utilisation d'IPv6 lors de l'envoi d'emails en exécutant yunohost settings set email.smtp.smtp_allow_ipv6 -v off. Remarque : cette dernière solution signifie que vous ne pourrez pas envoyer ou recevoir d'emails avec les quelques serveurs qui ont uniquement de l'IPv6.", @@ -590,7 +589,6 @@ "config_validate_email": "Doit être un email valide", "config_validate_time": "Doit être une heure valide comme HH:MM", "config_validate_url": "Doit être une URL Web valide", - "config_version_not_supported": "Les versions du panneau de configuration '{version}' ne sont pas prises en charge.", "danger": "Danger :", "invalid_number_min": "Doit être supérieur à {min}", "invalid_number_max": "Doit être inférieur à {max}", @@ -600,7 +598,6 @@ "domain_dns_push_not_applicable": "La fonction de configuration DNS automatique n'est pas applicable au domaine {domain}. Vous devez configurer manuellement vos enregistrements DNS en suivant la documentation sur https://yunohost.org/dns_config.", "domain_dns_registrar_yunohost": "Ce domaine est de type nohost.me / nohost.st / ynh.fr et sa configuration DNS est donc automatiquement gérée par YunoHost sans qu'il n'y ait d'autre configuration à faire. (voir la commande 'yunohost dyndns update')", "domain_dns_registrar_supported": "YunoHost a détecté automatiquement que ce domaine est géré par le registrar **{registrar}**. Si vous le souhaitez, YunoHost configurera automatiquement cette zone DNS, si vous lui fournissez les identifiants API appropriés. Vous pouvez trouver de la documentation sur la façon d'obtenir vos identifiants API sur cette page : https://yunohost.org/registar_api_{registrar}. (Vous pouvez également configurer manuellement vos enregistrements DNS en suivant la documentation sur https://yunohost.org/dns )", - "domain_config_features_disclaimer": "Jusqu'à présent, l'activation/désactivation des fonctionnalités de messagerie ou XMPP n'a d'impact que sur la configuration DNS recommandée et automatique, et non sur les configurations système !", "domain_dns_push_managed_in_parent_domain": "La fonctionnalité de configuration DNS automatique est gérée dans le domaine parent {parent_domain}.", "domain_dns_registrar_managed_in_parent_domain": "Ce domaine est un sous-domaine de {parent_domain_link}. La configuration du registrar DNS doit être gérée dans le panneau de configuration de {parent_domain}.", "domain_dns_registrar_not_supported": "YunoHost n'a pas pu détecter automatiquement le bureau d'enregistrement gérant ce domaine. Vous devez configurer manuellement vos enregistrements DNS en suivant la documentation sur https://yunohost.org/dns.", @@ -629,7 +626,7 @@ "diagnosis_http_special_use_tld": "Le domaine {domain} est basé sur un domaine de premier niveau (TLD) à usage spécial tel que .local ou .test et n'est donc pas censé être exposé en dehors du réseau local.", "domain_dns_conf_special_use_tld": "Ce domaine est basé sur un domaine de premier niveau (TLD) à usage spécial tel que .local ou .test et ne devrait donc pas avoir d'enregistrements DNS réels.", "other_available_options": "... et {n} autres options disponibles non affichées", - "domain_config_auth_consumer_key": "La clé utilisateur", + "domain_config_auth_consumer_key": "Clé utilisateur", "domain_unknown": "Domaine '{domain}' inconnu", "migration_0021_start": "Démarrage de la migration vers Bullseye", "migration_0021_patching_sources_list": "Mise à jour du fichier sources.lists...", @@ -654,10 +651,10 @@ "tools_upgrade_failed": "Impossible de mettre à jour les paquets : {packages_list}", "migration_0023_not_enough_space": "Prévoyez suffisamment d'espace disponible dans {path} pour exécuter la migration.", "migration_0023_postgresql_11_not_installed": "PostgreSQL n'a pas été installé sur votre système. Il n'y a rien à faire.", - "global_settings_setting_backup_compress_tar_archives_help": "Lors de la création de nouvelles sauvegardes, compresser automatiquement les archives (.tar.gz) au lieu des archives non compressées (.tar). N.B. : activer cette option permet de créer des archives plus légères, mais la procédure de sauvegarde initiale sera significativement plus longues et plus gourmandes en CPU.", + "global_settings_setting_backup_compress_tar_archives_help": "Lors de la création de nouvelles sauvegardes, compresser automatiquement les archives (.tar.gz) au lieu des archives non compressées (.tar).\nN.B. : activer cette option permet de créer des archives plus légères, mais la procédure de sauvegarde initiale sera significativement plus longues et plus gourmandes en CPU.", "global_settings_setting_security_experimental_enabled": "Fonctionnalités de sécurité expérimentales", "global_settings_setting_security_experimental_enabled_help": "Activer les fonctionnalités de sécurité expérimentales (ne l'activez pas si vous ne savez pas ce que vous faites !)", - "global_settings_setting_nginx_compatibility_help": "Compromis 'compatibilité versus sécurité' pour le serveur web Nginx. Affecte les cryptogrammes utilisés (et d'autres aspects liés à la sécurité)", + "global_settings_setting_nginx_compatibility_help": "Compromis 'compatibilité versus sécurité' pour le serveur web NGINX. Affecte les cryptogrammes utilisés (et d'autres aspects liés à la sécurité)", "global_settings_setting_nginx_redirect_to_https_help": "Rediriger les requêtes HTTP vers HTTPS par défaut (NE PAS DÉSACTIVER à moins de savoir vraiment ce que vous faites !)", "global_settings_setting_admin_strength": "Critères pour les mots de passe administrateur", "global_settings_setting_user_strength": "Critères pour les mots de passe utilisateurs", @@ -710,7 +707,7 @@ "visitors": "Visiteurs", "global_settings_reset_success": "Réinitialisation des paramètres généraux", "domain_config_acme_eligible": "Éligibilité au protocole ACME (Automatic Certificate Management Environment, littéralement : environnement de gestion automatique de certificat)", - "domain_config_acme_eligible_explain": "Ce domaine ne semble pas près pour installer un certificat Let's Encrypt. Veuillez vérifier votre configuration DNS mais aussi que votre serveur est bien joignable en HTTP. Les sections 'Enregistrements DNS' et 'Web' de la page Diagnostic peuvent vous aider à comprendre ce qui est mal configuré.", + "domain_config_acme_eligible_explain": "Ce domaine ne semble pas prêt pour installer un certificat Let's Encrypt. Veuillez vérifier votre configuration DNS mais aussi que votre serveur est bien joignable en HTTP. Les sections 'Enregistrements DNS' et 'Web' de la page Diagnostic peuvent vous aider à comprendre ce qui est mal configuré.", "domain_config_cert_install": "Installer un certificat Let's Encrypt", "domain_config_cert_issuer": "Autorité de certification", "domain_config_cert_no_checks": "Ignorer les tests et autres vérifications du diagnostic", @@ -738,9 +735,32 @@ "global_settings_setting_smtp_allow_ipv6": "Autoriser l'IPv6", "password_too_long": "Veuillez choisir un mot de passe de moins de 127 caractères", "domain_cannot_add_muc_upload": "Vous ne pouvez pas ajouter de domaines commençant par 'muc.'. Ce type de nom est réservé à la fonction de chat XMPP multi-utilisateurs intégrée à YunoHost.", - "group_update_aliases": "Mise à jour des alias du groupe '{group}'.", + "group_update_aliases": "Mise à jour des alias du groupe '{group}'", "group_no_change": "Rien à mettre à jour pour le groupe '{group}'", "global_settings_setting_portal_theme": "Thème du portail", "global_settings_setting_portal_theme_help": "Pour plus d'informations sur la création de thèmes de portail personnalisés, voir https://yunohost.org/theming", - "global_settings_setting_passwordless_sudo": "Permettre aux administrateurs d'utiliser 'sudo' sans retaper leur mot de passe" + "global_settings_setting_passwordless_sudo": "Permettre aux administrateurs d'utiliser 'sudo' sans retaper leur mot de passe", + "app_arch_not_supported": "Cette application ne peut être installée que sur les architectures {required}. L'architecture de votre serveur est {current}", + "app_resource_failed": "L'allocation automatique des ressources (provisioning), la suppression d'accès à ces ressources (déprovisioning) ou la mise à jour des ressources pour {app} a échoué : {error}", + "confirm_app_insufficient_ram": "ATTENTION ! Cette application requiert {required} de RAM pour l'installation/mise à niveau mais il n'y a que {current} de disponible actuellement. Même si cette application pouvait fonctionner, son processus d'installation/mise à niveau nécessite une grande quantité de RAM. Votre serveur pourrait donc geler et planter lamentablement. Si vous êtes prêt à prendre ce risque, tapez '{answers}'", + "app_not_enough_disk": "Cette application nécessite {required} d'espace libre.", + "app_not_enough_ram": "Cette application nécessite {required} de mémoire vive (RAM) pour être installée/mise à niveau mais seule {current} de mémoire est disponible actuellement.", + "app_yunohost_version_not_supported": "Cette application nécessite une version de YunoHost >= {required}. La version installée est {current}", + "confirm_notifications_read": "AVERTISSEMENT : Vous devriez vérifier les notifications de l'application susmentionnée avant de continuer, il pourrait y avoir des informations importantes à connaître. [{answers}]", + "invalid_shell": "Shell invalide : {shell}", + "global_settings_setting_dns_exposure": "Suites d'IP à prendre en compte pour la configuration et le diagnostic du DNS", + "global_settings_setting_dns_exposure_help": "NB : Ceci n'affecte que la configuration DNS recommandée et les vérifications de diagnostic. Cela n'affecte pas les configurations du système.", + "diagnosis_ip_no_ipv6_tip_important": "IPv6 devrait généralement être configuré automatiquement par le système ou par votre fournisseur d'accès à internet (FAI) s'il est disponible. Sinon, vous devrez peut-être configurer quelques éléments manuellement, comme expliqué dans la documentation ici : https://yunohost.org/#/ipv6.", + "domain_config_default_app_help": "Les personnes seront automatiquement redirigées vers cette application lorsqu'elles ouvriront ce domaine. Si aucune application n'est spécifiée, les personnes sont redirigées vers le formulaire de connexion du portail utilisateur.", + "domain_config_xmpp_help": "NB : certaines fonctions XMPP nécessiteront la mise à jour de vos enregistrements DNS et la régénération de votre certificat Lets Encrypt pour être activées", + "app_change_url_failed": "Impossible de modifier l'url de {app} : {error}", + "app_change_url_require_full_domain": "{app} ne peut pas être déplacée vers cette nouvelle URL car elle nécessite un domaine complet (c'est-à-dire avec un chemin = /)", + "app_change_url_script_failed": "Une erreur s'est produite dans le script de modification de l'url", + "app_failed_to_upgrade_but_continue": "La mise à jour de l'application {failed_app} a échoué, mais YunoHost va continuer avec les mises à jour suivantes comme demandé. Lancez 'yunohost log show {operation_logger_name}' pour voir le journal des échecs", + "app_not_upgraded_broken_system_continue": "L'application '{failed_app}' n'a pas réussi à se mettre à jour et a mis le système dans un état alternatif car quelque chose est au moins momentanément \"cassé\" (le paramètre --continue-on-failure est donc ignoré). La conséquence est que les mises à jour des applications suivantes ont été annulées : {apps}", + "app_not_upgraded_broken_system": "L'application '{failed_app}' n'a pas réussi à se mettre à jour et a mis le système dans un état alternatif car quelque chose est au moins momentanément \"cassé\". En conséquence, les mises à jour des applications suivantes ont été annulées : {apps}", + "apps_failed_to_upgrade": "Ces applications n'ont pas pu être mises à jour : {apps}", + "apps_failed_to_upgrade_line": "\n * {app_id} (pour voir le journal/log correspondant, faites un 'yunohost log show {operation_logger_name}')", + "app_failed_to_download_asset": "Échec du téléchargement de la ressource '{source_id}' ({url}) pour {app} : {out}", + "app_corrupt_source": "YunoHost a pu télécharger la ressource '{source_id}' ({url}) pour {app}, malheureusement celle-ci ne correspond pas à la somme de contrôle attendue. Cela peut signifier qu'une défaillance temporaire du réseau s'est produite sur votre serveur, OU que la ressource a été modifiée par le mainteneur de l'application en amont (ou un acteur malveillant ?) et que les responsables du paquet de cette application pour YunoHost doivent investiguer et mettre à jour le manifeste de l'application pour refléter ce changement.\n Somme de contrôle sha256 attendue : {expected_sha256}\n Somme de contrôle sha256 téléchargée : {computed_sha256}\n Taille du fichier téléchargé : {taille}" } diff --git a/locales/gl.json b/locales/gl.json index 61af0b672..c5e5c68c0 100644 --- a/locales/gl.json +++ b/locales/gl.json @@ -82,7 +82,7 @@ "app_change_url_success": "A URL de {app} agora é {domain}{path}", "app_change_url_no_script": "A app '{app_name}' non soporta o cambio de URL. Pode que debas actualizala.", "app_change_url_identical_domains": "O antigo e o novo dominio/url_path son idénticos ('{domain}{path}'), nada que facer.", - "backup_deleted": "Copia de apoio eliminada", + "backup_deleted": "Copia eliminada: {name}", "backup_delete_error": "Non se eliminou '{path}'", "backup_custom_mount_error": "O método personalizado de copia non superou o paso 'mount'", "backup_custom_backup_error": "O método personalizado da copia non superou o paso 'backup'", @@ -90,7 +90,7 @@ "backup_csv_addition_failed": "Non se engadiron os ficheiros a copiar ao ficheiro CSV", "backup_creation_failed": "Non se puido crear o arquivo de copia de apoio", "backup_create_size_estimation": "O arquivo vai conter arredor de {size} de datos.", - "backup_created": "Copia de apoio creada", + "backup_created": "Copia creada: {name}", "backup_couldnt_bind": "Non se puido ligar {src} a {dest}.", "backup_copying_to_organize_the_archive": "Copiando {size}MB para organizar o arquivo", "backup_cleaning_failed": "Non se puido baleirar o cartafol temporal para a copia", @@ -347,7 +347,6 @@ "log_domain_remove": "Eliminar o dominio '{}' da configuración do sistema", "log_domain_add": "Engadir dominio '{}' á configuración do sistema", "log_remove_on_failed_install": "Eliminar '{}' tras unha instalación fallida", - "log_remove_on_failed_restore": "Eliminar '{}' tras un intento fallido de restablecemento desde copia", "log_backup_restore_app": "Restablecer '{}' desde unha copia de apoio", "log_backup_restore_system": "Restablecer o sistema desde unha copia de apoio", "log_backup_create": "Crear copia de apoio", @@ -590,7 +589,6 @@ "log_app_config_set": "Aplicar a configuración á app '{}'", "app_config_unable_to_apply": "Fallou a aplicación dos valores de configuración.", "config_cant_set_value_on_section": "Non podes establecer un valor único na sección completa de configuración.", - "config_version_not_supported": "A versión do panel de configuración '{version}' non está soportada.", "invalid_number_max": "Ten que ser menor de {max}", "service_not_reloading_because_conf_broken": "Non se recargou/reiniciou o servizo '{name}' porque a súa configuración está estragada: {errors}", "diagnosis_http_special_use_tld": "O dominio {domain} baséase nun dominio de alto-nivel (TLD) especial como .local ou .test e por isto non é de agardar que esté exposto fóra da rede local.", @@ -604,7 +602,6 @@ "domain_dns_push_record_failed": "Fallou {action} do rexistro {type}/{name}: {error}", "domain_dns_push_success": "Rexistros DNS actualizados!", "domain_dns_push_failed": "Fallou completamente a actualización dos rexistros DNS.", - "domain_config_features_disclaimer": "Ata o momento, activar/desactivar as funcións de email ou XMPP só ten impacto na configuración automática da configuración DNS, non na configuración do sistema!", "domain_config_mail_in": "Emails entrantes", "domain_config_mail_out": "Emails saíntes", "domain_config_xmpp": "Mensaxería instantánea (XMPP)", @@ -741,5 +738,29 @@ "domain_cannot_add_muc_upload": "Non podes engadir dominios que comecen por 'muc.'. Este tipo de dominio está reservado para as salas de conversa de XMPP integradas en YunoHost.", "global_settings_setting_passwordless_sudo": "Permitir a Admins usar 'sudo' sen ter que volver a escribir o contrasinal", "global_settings_setting_portal_theme": "Decorado do Portal", - "global_settings_setting_portal_theme_help": "Tes máis info acerca da creación de decorados para o portal de acceso en https://yunohost.org/theming" + "global_settings_setting_portal_theme_help": "Tes máis info acerca da creación de decorados para o portal de acceso en https://yunohost.org/theming", + "app_arch_not_supported": "Esta app só pode ser instalada e arquitecturas {required} pero a arquitectura do teu servidor é {current}", + "app_not_enough_disk": "Esta app precisa {required} de espazo libre.", + "app_yunohost_version_not_supported": "Esta app require YunoHost >= {required} pero a versión actual instalada é {current}", + "confirm_app_insufficient_ram": "PERIGO! Esta app precisa {required} de RAM para instalar/actualizar pero só hai {current} dispoñibles. Incluso se a app funcionase, o seu proceso de instalación/actualización require gran cantidade de RAM e o teu servidor podería colgarse e fallar. Se queres asumir o risco, escribe '{answers}'", + "confirm_notifications_read": "AVISO: Deberías comprobar as notificacións da app antes de continuar, poderías ter información importante que revisar. [{answers}]", + "app_not_enough_ram": "Esta app require {required} de RAM para instalar/actualizar pero só hai {current} dispoñible.", + "global_settings_setting_dns_exposure": "Versións de IP a ter en conta para a configuración DNS e diagnóstico", + "global_settings_setting_dns_exposure_help": "Nota: Esto só lle afecta á configuración DNS recomendada e diagnóstico do sistema. Non lle afecta aos axustes do sistema.", + "diagnosis_ip_no_ipv6_tip_important": "Se está dispoñible, IPv6 debería estar automáticamente configurado polo sistema ou o teu provedor. Se non, pode que teñas que facer algúns axustes manualmente tal como se explica na documentación: https://yunohost.org/#/ipv6.", + "domain_config_default_app_help": "As persoas serán automáticamente redirixidas a esta app ao abrir o dominio. Se non se indica ningunha, serán redirixidas ao formulario de acceso no portal de usuarias.", + "domain_config_xmpp_help": "Nota: algunhas características de XMPP para ser utilizadas precisan que teñas ao día os rexistros DNS e rexeneres os certificados Lets Encrypt", + "app_change_url_failed": "Non se cambiou o url para {app}: {error}", + "app_change_url_require_full_domain": "{app} non se pode mover a este novo URL porque require un dominio completo propio (ex. con ruta = /)", + "app_change_url_script_failed": "Algo fallou ao executar o script de cambio de url", + "apps_failed_to_upgrade_line": "\n * {app_id} (para ver o rexistro correspondente executa 'yunohost log show {operation_logger_name}')", + "app_failed_to_upgrade_but_continue": "Fallou a actualización de {failed_app}, seguimos coas demáis actualizacións. Executa 'yunohost log show {operation_logger_name}' para ver o rexistro do fallo", + "app_not_upgraded_broken_system": "Fallou a actualización de '{failed_app}' e estragou o sistema, como consecuencia cancelouse a actualización das seguintes apps: {apps}", + "app_not_upgraded_broken_system_continue": "Fallou a actualización de '{failed_app}' e estragou o sistema (polo que ignórase --continue-on-failure), como consecuencia cancelouse a actualización das seguintes apps: {apps}", + "apps_failed_to_upgrade": "Fallou a actualización das seguintes aplicacións:{apps}", + "invalid_shell": "Intérprete de ordes non válido: {shell}", + "log_resource_snippet": "Aprovisionamento/desaprovisionamento/actualización dun recurso", + "app_resource_failed": "Fallou o aprovisionamento, desaprovisionamento ou actualización de recursos para {app}: {error}", + "app_failed_to_download_asset": "Fallou a descarga do recurso '{source_id}' ({url}) para {app}: {out}", + "app_corrupt_source": "YunoHost foi quen de descargar o recurso '{source_id}' ({url}) para {app}, pero a suma de comprobación para o recurso non concorda. Pode significar que houbo un fallo temporal na conexión do servidor á rede, OU que o recurso sufreu, dalgún xeito, cambios desde que os desenvolvedores orixinais (ou unha terceira parte maliciosa?), o equipo de YunoHost ten que investigar e actualizar o manifesto da app para mostrar este cambio.\n Suma sha256 agardada: {expected_sha256} \n Suma sha256 do descargado: {computed_sha256}\n Tamaño do ficheiro: {size}" } diff --git a/locales/he.json b/locales/he.json index 0967ef424..9e26dfeeb 100644 --- a/locales/he.json +++ b/locales/he.json @@ -1 +1 @@ -{} +{} \ No newline at end of file diff --git a/locales/it.json b/locales/it.json index 9bb923c2a..21fb52367 100644 --- a/locales/it.json +++ b/locales/it.json @@ -233,7 +233,6 @@ "log_available_on_yunopaste": "Questo registro è ora disponibile via {url}", "log_backup_restore_system": "Ripristina sistema da un archivio di backup", "log_backup_restore_app": "Ripristina '{}' da un archivio di backup", - "log_remove_on_failed_restore": "Rimuovi '{}' dopo un ripristino fallito da un archivio di backup", "log_remove_on_failed_install": "Rimuovi '{}' dopo un'installazione fallita", "log_domain_add": "Aggiungi il dominio '{}' nella configurazione di sistema", "log_domain_remove": "Rimuovi il dominio '{}' dalla configurazione di sistema", @@ -612,14 +611,12 @@ "domain_dns_push_success": "Record DNS aggiornati!", "domain_dns_push_failed": "L’aggiornamento dei record DNS è miseramente fallito.", "domain_dns_push_partial_failure": "Record DNS parzialmente aggiornati: alcuni segnali/errori sono stati riportati.", - "domain_config_features_disclaimer": "Per ora, abilitare/disabilitare le impostazioni di posta o XMPP impatta unicamente sulle configurazioni DNS raccomandate o ottimizzate, non cambia quelle di sistema!", "domain_config_mail_in": "Email in arrivo", "domain_config_auth_application_key": "Chiave applicazione", "domain_config_auth_application_secret": "Chiave segreta applicazione", "domain_config_auth_consumer_key": "Chiave consumatore", "ldap_attribute_already_exists": "L’attributo LDAP '{attribute}' esiste già con il valore '{value}'", "config_validate_time": "È necessario inserire un orario valido, come HH:MM", - "config_version_not_supported": "Le versioni '{version}' del pannello di configurazione non sono supportate.", "danger": "Attenzione:", "log_domain_config_set": "Aggiorna la configurazione per il dominio '{}'", "domain_dns_push_managed_in_parent_domain": "La configurazione automatica del DNS è gestita nel dominio genitore {parent_domain}.", @@ -640,5 +637,6 @@ "global_settings_setting_webadmin_allowlist_enabled_help": "Permetti solo ad alcuni IP di accedere al webadmin.", "global_settings_setting_smtp_allow_ipv6_help": "Permetti l'utilizzo di IPv6 per ricevere e inviare mail", "global_settings_setting_smtp_relay_enabled_help": "Utilizza SMTP relay per inviare mail al posto di questa instanza yunohost. Utile se sei in una di queste situazioni: la tua porta 25 è bloccata dal tuo provider ISP o VPS; hai un IP residenziale listato su DUHL; non sei puoi configurare il DNS inverso; oppure questo server non è direttamente esposto a Internet e vuoi usarne un'altro per spedire email.", - "domain_config_default_app": "Applicazione di default" + "domain_config_default_app": "Applicazione di default", + "app_change_url_failed": "Non è possibile cambiare l'URL per {app}:{error}" } \ No newline at end of file diff --git a/locales/lt.json b/locales/lt.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/locales/lt.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/locales/nb_NO.json b/locales/nb_NO.json index d74f47728..8cacaff6d 100644 --- a/locales/nb_NO.json +++ b/locales/nb_NO.json @@ -29,7 +29,6 @@ "downloading": "Laster ned…", "dyndns_could_not_check_available": "Kunne ikke sjekke om {domain} er tilgjengelig på {provider}.", "mail_domain_unknown": "Ukjent e-postadresse for domenet '{domain}'", - "log_remove_on_failed_restore": "Fjern '{}' etter mislykket gjenoppretting fra sikkerhetskopiarkiv", "log_letsencrypt_cert_install": "Installer et Let's Encrypt-sertifikat på '{}'-domenet", "log_letsencrypt_cert_renew": "Forny '{}'-Let's Encrypt-sertifikat", "log_user_update": "Oppdater brukerinfo for '{}'", diff --git a/locales/nl.json b/locales/nl.json index 24ade2f5c..bcfb76acd 100644 --- a/locales/nl.json +++ b/locales/nl.json @@ -139,4 +139,4 @@ "group_already_exist_on_system": "Groep {group} bestaat al in de systeemgroepen", "good_practices_about_admin_password": "Je gaat nu een nieuw beheerderswachtwoordopgeven. Het wachtwoord moet minimaal 8 tekens lang zijn, hoewel het een goede gewoonte is om een langer wachtwoord te gebruiken (d.w.z. een wachtwoordzin) en/of een variatie van tekens te gebruiken (hoofdletters, kleine letters, cijfers en speciale tekens).", "good_practices_about_user_password": "Je gaat nu een nieuw gebruikerswachtwoord pgeven. Het wachtwoord moet minimaal 8 tekens lang zijn, hoewel het een goede gewoonte is om een langer wachtwoord te gebruiken (d.w.z. een wachtwoordzin) en/of een variatie van tekens te gebruiken (hoofdletters, kleine letters, cijfers en speciale tekens)." -} +} \ No newline at end of file diff --git a/locales/oc.json b/locales/oc.json index 6282a6cec..1c13fc6b5 100644 --- a/locales/oc.json +++ b/locales/oc.json @@ -238,7 +238,6 @@ "log_available_on_yunopaste": "Lo jornal es ara disponible via {url}", "log_backup_restore_system": "Restaurar lo sistèma a partir d’una salvagarda", "log_backup_restore_app": "Restaurar « {} » a partir d’una salvagarda", - "log_remove_on_failed_restore": "Levar « {} » aprèp un fracàs de restauracion a partir d’una salvagarda", "log_remove_on_failed_install": "Tirar « {} » aprèp una installacion pas reüssida", "log_domain_add": "Ajustar lo domeni « {} » dins la configuracion sistèma", "log_domain_remove": "Tirar lo domeni « {} » d’a la configuracion sistèma", @@ -380,7 +379,7 @@ "diagnosis_services_bad_status": "Lo servici {service} es {status} :(", "diagnosis_swap_ok": "Lo sistèma a {total} d’escambi !", "diagnosis_regenconf_allgood": "Totes los fichièrs de configuracion son confòrmes a la configuracion recomandada !", - "diagnosis_regenconf_manually_modified": "Lo fichièr de configuracion {file} foguèt modificat manualament.", + "diagnosis_regenconf_manually_modified": "Lo fichièr de configuracion {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 '; ось список доменів-кандидатів: {other_domains}", "disk_space_not_sufficient_update": "Недостатньо місця на диску для оновлення цього застосунку", "disk_space_not_sufficient_install": "Недостатньо місця на диску для встановлення цього застосунку", - "diagnosis_sshd_config_inconsistent_details": "Будь ласка, виконайте команду yunohost settings set security.ssh.ssh port -v YOUR_SSH_PORT, щоб визначити порт SSH, і перевіртеyunohost tools regen-conf ssh --dry-run --with-diff і yunohost tools regen-conf ssh --force, щоб скинути ваш конфіг на рекомендований YunoHost.", + "diagnosis_sshd_config_inconsistent_details": "Будь ласка, виконайте команду yunohost settings set security.ssh.ssh port -v ВАШ_SSH_ПОРТ, щоб визначити порт SSH, і перевіртеyunohost tools regen-conf ssh --dry-run --with-diff і yunohost tools regen-conf ssh --force, щоб скинути ваш конфіг на рекомендований YunoHost.", "diagnosis_sshd_config_inconsistent": "Схоже, що порт SSH був уручну змінений в /etc/ssh/sshd_config. Починаючи з версії YunoHost 4.2, доступний новий глобальний параметр 'security.ssh.ssh port', що дозволяє уникнути ручного редагування конфігурації.", "diagnosis_sshd_config_insecure": "Схоже, що конфігурація SSH була змінена вручну і є небезпечною, оскільки не містить директив 'AllowGroups' або 'AllowUsers' для обмеження доступу авторизованих користувачів.", "diagnosis_processes_killed_by_oom_reaper": "Деякі процеси було недавно вбито системою через брак пам'яті. Зазвичай це є симптомом нестачі пам'яті в системі або процесу, який з'їв дуже багато пам'яті. Зведення убитих процесів:\n{kills_summary}", @@ -347,7 +346,7 @@ "diagnosis_mail_outgoing_port_25_blocked_details": "Спочатку спробуйте розблокувати вихідний порт 25 в інтерфейсі вашого інтернет-маршрутизатора або в інтерфейсі вашого хостинг-провайдера. (Деякі хостинг-провайдери можуть вимагати, щоб ви відправили їм заявку в службу підтримки).", "diagnosis_mail_outgoing_port_25_blocked": "Поштовий сервер SMTP не може відправляти електронні листи на інші сервери, оскільки вихідний порт 25 заблоковано в IPv{ipversion}.", "app_manifest_install_ask_path": "Оберіть шлях URL (після домену), за яким має бути встановлено цей застосунок", - "yunohost_postinstall_end_tip": "Післявстановлення завершено! Щоб завершити доналаштування, будь ласка, розгляньте наступні варіанти:\n - додавання першого користувача через розділ 'Користувачі' вебадмініструванні (або 'yunohost user create ' в командному рядку);\n - діагностика можливих проблем через розділ 'Діагностика' вебадмініструванні (або 'yunohost diagnosis run' в командному рядку);\n - прочитання розділів 'Завершення встановлення' і 'Знайомство з YunoHost' у документації адміністратора: https://yunohost.org/admindoc.", + "yunohost_postinstall_end_tip": "Післявстановлення завершено! Щоб завершити доналаштування, будь ласка, розгляньте наступні варіанти:\n - діагностика можливих проблем через розділ 'Діагностика' вебадмініструванні (або 'yunohost diagnosis run' в командному рядку);\n - прочитання розділів 'Завершення встановлення' і 'Знайомство з YunoHost' у документації адміністратора: https://yunohost.org/admindoc.", "yunohost_not_installed": "YunoHost установлений неправильно. Будь ласка, запустіть 'yunohost tools postinstall'", "yunohost_installing": "Установлення YunoHost...", "yunohost_configured": "YunoHost вже налаштовано", @@ -489,7 +488,7 @@ "backup_method_custom_finished": "Користувацький спосіб резервного копіювання '{method}' завершено", "backup_method_copy_finished": "Резервне копіювання завершено", "backup_hook_unknown": "Гачок (hook) резервного копіювання '{hook}' невідомий", - "backup_deleted": "Резервна копія видалена", + "backup_deleted": "Резервна копія '{name}' видалена", "backup_delete_error": "Не вдалося видалити '{path}'", "backup_custom_mount_error": "Користувацький спосіб резервного копіювання не зміг пройти етап 'монтування'", "backup_custom_backup_error": "Користувацький спосіб резервного копіювання не зміг пройти етап 'резервне копіювання'", @@ -497,7 +496,7 @@ "backup_csv_addition_failed": "Не вдалося додати файли для резервного копіювання в CSV-файл", "backup_creation_failed": "Не вдалося створити архів резервного копіювання", "backup_create_size_estimation": "Архів буде містити близько {size} даних.", - "backup_created": "Резервна копія створена", + "backup_created": "Резервна копія '{name}' створена", "backup_couldnt_bind": "Не вдалося зв'язати {src} з {dest}.", "backup_copying_to_organize_the_archive": "Копіювання {size} МБ для організації архіву", "backup_cleaning_failed": "Не вдалося очистити тимчасовий каталог резервного копіювання", @@ -587,7 +586,6 @@ "config_validate_email": "Е-пошта має бути дійсною", "config_validate_time": "Час має бути дійсним, наприклад ГГ:ХХ", "config_validate_url": "Вебадреса має бути дійсною", - "config_version_not_supported": "Версії конфігураційної панелі '{version}' не підтримуються.", "danger": "Небезпека:", "invalid_number_min": "Має бути більшим за {min}", "invalid_number_max": "Має бути меншим за {max}", @@ -614,7 +612,6 @@ "domain_dns_push_failed_to_authenticate": "Неможливо пройти автентифікацію на API реєстратора для домену '{domain}'. Ймовірно, облікові дані недійсні? (Помилка: {error})", "domain_dns_push_failed_to_list": "Не вдалося скласти список поточних записів за допомогою API реєстратора: {error}", "domain_dns_push_record_failed": "Не вдалося виконати дію {action} запису {type}/{name} : {error}", - "domain_config_features_disclaimer": "Поки що вмикання/вимикання функцій пошти або XMPP впливає тільки на рекомендовану та автоконфігурацію DNS, але не на конфігурацію системи!", "domain_config_xmpp": "Миттєвий обмін повідомленнями (XMPP)", "domain_config_auth_key": "Ключ автентифікації", "domain_config_auth_secret": "Секрет автентифікації", @@ -657,7 +654,7 @@ "global_settings_setting_admin_strength": "Надійність пароля адміністратора", "global_settings_setting_user_strength": "Надійність пароля користувача", "global_settings_setting_postfix_compatibility_help": "Компроміс між сумісністю і безпекою для сервера Postfix. Впливає на шифри (і інші аспекти, пов'язані з безпекою)", - "global_settings_setting_ssh_compatibility_help": "Компроміс між сумісністю і безпекою для SSH-сервера. Впливає на шифри (і інші аспекти, пов'язані з безпекою)", + "global_settings_setting_ssh_compatibility_help": "Компроміс між сумісністю і безпекою для SSH-сервера. Впливає на шифри (і інші аспекти, пов'язані з безпекою).", "global_settings_setting_ssh_password_authentication_help": "Дозволити автентифікацію паролем для SSH", "global_settings_setting_ssh_port": "SSH-порт", "global_settings_setting_webadmin_allowlist_help": "IP-адреси, яким дозволений доступ до вебадмініструванні. Через кому.", @@ -738,5 +735,30 @@ "visitors": "Відвідувачі", "password_confirmation_not_the_same": "Пароль і його підтвердження не збігаються", "password_too_long": "Будь ласка, виберіть пароль коротший за 127 символів", - "pattern_fullname": "Має бути дійсне повне ім’я (принаймні 3 символи)" -} + "pattern_fullname": "Має бути дійсне повне ім’я (принаймні 3 символи)", + "app_failed_to_upgrade_but_continue": "Застосунок {failed_app} не вдалося оновити, продовжуйте наступні оновлення відповідно до запиту. Запустіть 'yunohost log show {operation_logger_name}', щоб побачити журнал помилок", + "app_not_upgraded_broken_system": "Застосунок '{failed_app}' не зміг оновитися і перевів систему в неробочий стан, і як наслідок, оновлення наступних застосунків було скасовано: {apps}", + "app_not_upgraded_broken_system_continue": "Застосунок '{failed_app}' не зміг оновитися і перевів систему у неробочий стан (тому --continue-on-failure ігнорується), і як наслідок, оновлення наступних застосунків було скасовано: {apps}", + "confirm_app_insufficient_ram": "НЕБЕЗПЕКА! Цей застосунок вимагає {required} оперативної пам'яті для встановлення/оновлення, але зараз доступно лише {current}. Навіть якби цей застосунок можна було б запустити, процес його встановлення/оновлення вимагає великої кількості оперативної пам'яті, тому ваш сервер може зависнути і вийти з ладу. Якщо ви все одно готові піти на цей ризик, введіть '{answers}'", + "invalid_shell": "Недійсна оболонка: {shell}", + "domain_config_default_app_help": "Користувачі будуть автоматично перенаправлятися на цей застосунок при відкритті цього домену. Якщо застосунок не вказано, люди будуть перенаправлені на форму входу на портал користувача.", + "domain_config_xmpp_help": "Примітка: для ввімкнення деяких функцій XMPP потрібно оновити записи DNS та відновити сертифікат Lets Encrypt", + "global_settings_setting_dns_exposure_help": "Примітка: Це стосується лише рекомендованої конфігурації DNS і діагностичних перевірок. Це не впливає на конфігурацію системи.", + "global_settings_setting_passwordless_sudo": "Дозвіл адміністраторам використовувати \"sudo\" без повторного введення пароля", + "app_change_url_failed": "Не вдалося змінити url для {app}: {error}", + "app_change_url_require_full_domain": "{app} не може бути переміщено на цю нову URL-адресу, оскільки для цього потрібен повний домен (тобто зі шляхом = /)", + "app_change_url_script_failed": "Виникла помилка всередині скрипта зміни URL-адреси", + "app_yunohost_version_not_supported": "Для роботи застосунку потрібен YunoHost мінімум версії {required}, але поточна встановлена версія {current}", + "app_arch_not_supported": "Цей застосунок можна встановити лише на архітектурах {required}, але архітектура вашого сервера {current}", + "global_settings_setting_dns_exposure": "Версії IP, які слід враховувати при конфігурації та діагностиці DNS", + "domain_cannot_add_muc_upload": "Ви не можете додавати домени, що починаються на 'muc.'. Такі імена зарезервовані для багатокористувацького чату XMPP, інтегрованого в YunoHost.", + "confirm_notifications_read": "ПОПЕРЕДЖЕННЯ: Перш ніж продовжити, перевірте сповіщення застосунку вище, там можуть бути важливі повідомлення. [{answers}]", + "global_settings_setting_portal_theme": "Тема порталу", + "global_settings_setting_portal_theme_help": "Подробиці щодо створення користувацьких тем порталу на https://yunohost.org/theming", + "diagnosis_ip_no_ipv6_tip_important": "Зазвичай IPv6 має бути автоматично налаштований системою або вашим провайдером, якщо він доступний. В іншому випадку, можливо, вам доведеться налаштувати деякі речі вручну, як описано в документації тут: https://yunohost.org/#/ipv6.", + "app_not_enough_disk": "Цей застосунок вимагає {required} вільного місця.", + "app_not_enough_ram": "Для встановлення/оновлення цього застосунку потрібно {required} оперативної пам'яті, але наразі доступно лише {current}.", + "app_resource_failed": "Не вдалося надати, позбавити або оновити ресурси для {app}: {error}", + "apps_failed_to_upgrade": "Ці застосунки не вдалося оновити:{apps}", + "apps_failed_to_upgrade_line": "\n * {app_id} (щоб побачити відповідний журнал, виконайте 'yunohost log show {operation_logger_name}')" +} \ No newline at end of file diff --git a/locales/zh_Hans.json b/locales/zh_Hans.json index 8aecbbce3..18c6430c0 100644 --- a/locales/zh_Hans.json +++ b/locales/zh_Hans.json @@ -28,9 +28,9 @@ "diagnosis_basesystem_hardware_model": "服务器型号为 {model}", "diagnosis_basesystem_hardware": "服务器硬件架构为{virt} {arch}", "custom_app_url_required": "您必须提供URL才能升级自定义应用 {app}", - "confirm_app_install_thirdparty": "危险! 该应用程序不是YunoHost的应用程序目录的一部分。 安装第三方应用程序可能会损害系统的完整性和安全性。 除非您知道自己在做什么,否则可能不应该安装它, 如果此应用无法运行或无法正常使用系统,将不会提供任何支持。如果您仍然愿意承担此风险,请输入'{answers}'", + "confirm_app_install_thirdparty": "危险! 该应用不是YunoHost的应用目录的一部分。 安装第三方应用可能会损害系统的完整性和安全性。 除非您知道自己在做什么,否则可能不应该安装它, 如果此应用无法运行或无法正常使用系统,将不会提供任何支持。如果您仍然愿意承担此风险,请输入'{answers}'", "confirm_app_install_danger": "危险! 已知此应用仍处于实验阶段(如果未明确无法正常运行)! 除非您知道自己在做什么,否则可能不应该安装它。 如果此应用无法运行或无法正常使用系统,将不会提供任何支持。如果您仍然愿意承担此风险,请输入'{answers}'", - "confirm_app_install_warning": "警告:此应用程序可能可以运行,但未与YunoHost很好地集成。某些功能(例如单点登录和备份/还原)可能不可用, 仍要安装吗? [{answers}] ", + "confirm_app_install_warning": "警告:此应用可能可以运行,但未与YunoHost很好地集成。某些功能(例如单点登录和备份/还原)可能不可用, 仍要安装吗? [{answers}] ", "certmanager_unable_to_parse_self_CA_name": "无法解析自签名授权的名称 (file: {file})", "certmanager_self_ca_conf_file_not_found": "找不到用于自签名授权的配置文件(file: {file})", "certmanager_no_cert_file": "无法读取域{domain}的证书文件(file: {file})", @@ -50,7 +50,7 @@ "certmanager_attempt_to_renew_valid_cert": "域'{domain}'的证书不会过期!(如果知道自己在做什么,则可以使用--force)", "certmanager_attempt_to_renew_nonLE_cert": "“Let's Encrypt”未颁发域'{domain}'的证书,无法自动续订!", "certmanager_acme_not_configured_for_domain": "目前无法针对{domain}运行ACME挑战,因为其nginx conf缺少相应的代码段...请使用“yunohost tools regen-conf nginx --dry-run --with-diff”确保您的nginx配置是最新的。", - "backup_with_no_restore_script_for_app": "{app} 没有还原脚本,您将无法自动还原该应用程序的备份。", + "backup_with_no_restore_script_for_app": "{app} 没有还原脚本,您将无法自动还原该应用的备份。", "backup_with_no_backup_script_for_app": "应用'{app}'没有备份脚本。无视。", "backup_unable_to_organize_files": "无法使用快速方法来组织档案中的文件", "backup_system_part_failed": "无法备份'{part}'系统部分", @@ -101,36 +101,36 @@ "ask_new_admin_password": "新的管理密码", "ask_main_domain": "主域", "ask_user_domain": "用户的电子邮件地址和XMPP帐户要使用的域", - "apps_catalog_update_success": "应用程序目录已更新!", - "apps_catalog_obsolete_cache": "应用程序目录缓存为空或已过时。", + "apps_catalog_update_success": "应用目录已更新!", + "apps_catalog_obsolete_cache": "应用目录缓存为空或已过时。", "apps_catalog_failed_to_download": "无法下载{apps_catalog} 应用目录: {error}", - "apps_catalog_updating": "正在更新应用程序目录…", + "apps_catalog_updating": "正在更新应用目录…", "apps_catalog_init_success": "应用目录系统已初始化!", - "apps_already_up_to_date": "所有应用程序都是最新的", + "apps_already_up_to_date": "所有应用都是最新的", "app_packaging_format_not_supported": "无法安装此应用,因为您的YunoHost版本不支持其打包格式。 您应该考虑升级系统。", "app_upgraded": "{app}upgraded", "app_upgrade_some_app_failed": "某些应用无法升级", "app_upgrade_script_failed": "应用升级脚本内部发生错误", "app_upgrade_app_name": "现在升级{app} ...", "app_upgrade_several_apps": "以下应用将被升级: {apps}", - "app_unsupported_remote_type": "应用程序使用的远程类型不受支持", + "app_unsupported_remote_type": "应用使用的远程类型不受支持", "app_start_backup": "正在收集要备份的文件,用于{app} ...", "app_start_install": "{app}安装中...", "app_sources_fetch_failed": "无法获取源文件,URL是否正确?", "app_restore_script_failed": "应用还原脚本内部发生错误", "app_restore_failed": "无法还原 {app}: {error}", - "app_remove_after_failed_install": "安装失败后删除应用程序...", + "app_remove_after_failed_install": "安装失败后删除应用...", "app_requirements_checking": "正在检查{app}所需的软件包...", "app_removed": "{app} 已卸载", "app_not_properly_removed": "{app} 未正确删除", "app_not_correctly_installed": "{app} 似乎安装不正确", - "app_not_upgraded": "应用程序'{failed_app}'升级失败,因此以下应用程序的升级已被取消: {apps}", + "app_not_upgraded": "应用'{failed_app}'升级失败,因此以下应用的升级已被取消: {apps}", "app_manifest_install_ask_is_public": "该应用是否应该向匿名访问者公开?", "app_manifest_install_ask_admin": "选择此应用的管理员用户", "app_manifest_install_ask_password": "选择此应用的管理密码", "additional_urls_already_removed": "权限'{permission}'的其他URL中已经删除了附加URL'{url}'", "app_manifest_install_ask_path": "选择安装此应用的路径(在域名之后)", - "app_manifest_install_ask_domain": "选择应安装此应用程序的域", + "app_manifest_install_ask_domain": "选择应安装此应用的域", "app_location_unavailable": "该URL不可用,或与已安装的应用冲突:\n{apps}", "app_label_deprecated": "不推荐使用此命令!请使用新命令 'yunohost user permission update'来管理应用标签。", "app_make_default_location_already_used": "无法将'{app}' 设置为域上的默认应用,'{other_app}'已在使用'{domain}'", @@ -138,10 +138,10 @@ "app_install_failed": "无法安装 {app}: {error}", "app_install_files_invalid": "这些文件无法安装", "additional_urls_already_added": "附加URL '{url}' 已添加到权限'{permission}'的附加URL中", - "app_full_domain_unavailable": "抱歉,此应用必须安装在其自己的域中,但其他应用已安装在域“ {domain}”上。 您可以改用专用于此应用程序的子域。", + "app_full_domain_unavailable": "抱歉,此应用必须安装在其自己的域中,但其他应用已安装在域“ {domain}”上。 您可以改用专用于此应用的子域。", "app_extraction_failed": "无法解压缩安装文件", "app_change_url_success": "{app} URL现在为 {domain}{path}", - "app_change_url_no_script": "应用程序'{app_name}'尚不支持URL修改. 也许您应该升级它。", + "app_change_url_no_script": "应用'{app_name}'尚不支持URL修改. 也许您应该升级它。", "app_change_url_identical_domains": "新旧domain / url_path是相同的('{domain}{path}'),无需执行任何操作。", "app_argument_required": "参数'{name}'为必填项", "app_argument_password_no_default": "解析密码参数'{name}'时出错:出于安全原因,密码参数不能具有默认值", @@ -156,7 +156,7 @@ "port_already_opened": "{ip_version}个连接的端口 {port} 已打开", "port_already_closed": "{ip_version}个连接的端口 {port} 已关闭", "permission_require_account": "权限{permission}只对有账户的用户有意义,因此不能对访客启用。", - "permission_protected": "权限{permission}是受保护的。你不能向/从这个权限添加或删除访问者组。", + "permission_protected": "权限{permission}是受保护的。您不能向/从这个权限添加或删除访问者组。", "permission_updated": "权限 '{permission}' 已更新", "permission_update_failed": "无法更新权限 '{permission}': {error}", "permission_not_found": "找不到权限'{permission}'", @@ -210,8 +210,8 @@ "service_description_rspamd": "过滤垃圾邮件和其他与电子邮件相关的功能", "service_description_redis-server": "用于快速数据访问,任务队列和程序之间通信的专用数据库", "service_description_postfix": "用于发送和接收电子邮件", - "service_description_nginx": "为你的服务器上托管的所有网站提供服务或访问", - "service_description_mysql": "存储应用程序数据(SQL数据库)", + "service_description_nginx": "为您的服务器上托管的所有网站提供服务或访问", + "service_description_mysql": "存储应用数据(SQL数据库)", "service_description_metronome": "管理XMPP即时消息传递帐户", "service_description_fail2ban": "防止来自互联网的暴力攻击和其他类型的攻击", "service_description_dovecot": "允许电子邮件客户端访问/获取电子邮件(通过IMAP和POP3)", @@ -234,7 +234,7 @@ "system_username_exists": "用户名已存在于系统用户列表中", "system_upgraded": "系统升级", "ssowat_conf_generated": "SSOwat配置已重新生成", - "show_tile_cant_be_enabled_for_regex": "你不能启用'show_tile',因为权限'{permission}'的URL是一个重合词", + "show_tile_cant_be_enabled_for_regex": "您不能启用'show_tile',因为权限'{permission}'的URL是一个重合词", "show_tile_cant_be_enabled_for_url_not_defined": "您现在无法启用 'show_tile' ,因为您必须先为权限'{permission}'定义一个URL", "service_unknown": "未知服务 '{service}'", "service_stopped": "服务'{service}' 已停止", @@ -266,7 +266,7 @@ "upnp_enabled": "UPnP已启用", "upnp_disabled": "UPnP已禁用", "yunohost_not_installed": "YunoHost没有正确安装,请运行 'yunohost tools postinstall'", - "yunohost_postinstall_end_tip": "后期安装完成! 为了最终完成你的设置,请考虑:\n -通过webadmin的“用户”部分添加第一个用户(或在命令行中'yunohost user create ' );\n -通过网络管理员的“诊断”部分(或命令行中的'yunohost diagnosis run')诊断潜在问题;\n -阅读管理文档中的“完成安装设置”和“了解YunoHost”部分: https://yunohost.org/admindoc.", + "yunohost_postinstall_end_tip": "后期安装完成! 为了最终完成您的设置,请考虑:\n -通过webadmin的“用户”部分添加第一个用户(或在命令行中'yunohost user create ' );\n -通过网络管理员的“诊断”部分(或命令行中的'yunohost diagnosis run')诊断潜在问题;\n -阅读管理文档中的“完成安装设置”和“了解YunoHost”部分: https://yunohost.org/admindoc.", "operation_interrupted": "该操作是否被手动中断?", "invalid_regex": "无效的正则表达式:'{regex}'", "installation_complete": "安装完成", @@ -318,13 +318,13 @@ "downloading": "下载中…", "done": "完成", "domains_available": "可用域:", - "domain_uninstall_app_first": "这些应用程序仍安装在您的域中:\n{apps}\n\n请先使用 'yunohost app remove the_app_id' 将其卸载,或使用 'yunohost app change-url the_app_id'将其移至另一个域,然后再继续删除域", - "domain_remove_confirm_apps_removal": "删除该域将删除这些应用程序:\n{apps}\n\n您确定要这样做吗? [{answers}]", + "domain_uninstall_app_first": "这些应用仍安装在您的域中:\n{apps}\n\n请先使用 'yunohost app remove the_app_id' 将其卸载,或使用 'yunohost app change-url the_app_id'将其移至另一个域,然后再继续删除域", + "domain_remove_confirm_apps_removal": "删除该域将删除这些应用:\n{apps}\n\n您确定要这样做吗? [{answers}]", "domain_hostname_failed": "无法设置新的主机名。稍后可能会引起问题(可能没问题)。", "domain_exists": "该域已存在", "domain_dyndns_root_unknown": "未知的DynDNS根域", "domain_dyndns_already_subscribed": "您已经订阅了DynDNS域", - "domain_dns_conf_is_just_a_recommendation": "本页向你展示了*推荐的*配置。它并*不*为你配置DNS。你有责任根据该建议在你的DNS注册商处配置你的DNS区域。", + "domain_dns_conf_is_just_a_recommendation": "本页向您展示了*推荐的*配置。它并*不*为您配置DNS。您有责任根据该建议在您的DNS注册商处配置您的DNS区域。", "domain_deletion_failed": "无法删除域 {domain}: {error}", "domain_deleted": "域已删除", "domain_creation_failed": "无法创建域 {domain}: {error}", @@ -369,7 +369,7 @@ "diagnosis_description_ip": "互联网连接", "diagnosis_description_basesystem": "基本系统", "diagnosis_security_vulnerable_to_meltdown_details": "要解决此问题,您应该升级系统并重新启动以加载新的Linux内核(如果无法使用,请与您的服务器提供商联系)。有关更多信息,请参见https://meltdownattack.com/。", - "diagnosis_security_vulnerable_to_meltdown": "你似乎容易受到Meltdown关键安全漏洞的影响", + "diagnosis_security_vulnerable_to_meltdown": "您似乎容易受到Meltdown关键安全漏洞的影响", "diagnosis_regenconf_manually_modified": "配置文件 {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的情况下还有一些不一致的地方。为了解决这种情况,你应该尝试运行以下命令:{cmd_to_fix}", + "diagnosis_package_installed_from_sury_details": "一些软件包被无意中从一个名为Sury的第三方仓库安装。YunoHost团队改进了处理这些软件包的策略,但预计一些安装了PHP7.3应用的设置在仍然使用Stretch的情况下还有一些不一致的地方。为了解决这种情况,您应该尝试运行以下命令:{cmd_to_fix}", "app_not_installed": "在已安装的应用列表中找不到 {app}:{all_apps}", - "app_already_installed_cant_change_url": "这个应用程序已经被安装。URL不能仅仅通过这个函数来改变。在`app changeurl`中检查是否可用。", + "app_already_installed_cant_change_url": "这个应用已经被安装。URL不能仅仅通过这个函数来改变。在`app changeurl`中检查是否可用。", "restore_not_enough_disk_space": "没有足够的空间(空间: {free_space} B,需要的空间: {needed_space} B,安全系数: {margin} B)", "regenconf_pending_applying": "正在为类别'{category}'应用挂起的配置..", "regenconf_up_to_date": "类别'{category}'的配置已经是最新的", "regenconf_file_kept_back": "配置文件'{conf}'预计将被regen-conf(类别{category})删除,但被保留了下来。", "good_practices_about_user_password": "现在,您将设置一个新的管理员密码。 密码至少应包含8个字符。并且出于安全考虑建议使用较长的密码同时尽可能使用各种字符(大写,小写,数字和特殊字符)", - "domain_cannot_remove_main_add_new_one": "你不能删除'{domain}',因为它是主域和你唯一的域,你需要先用'yunohost domain add '添加另一个域,然后用'yunohost domain main-domain -n '设置为主域,然后你可以用'yunohost domain remove {domain}'删除域", - "domain_cannot_add_xmpp_upload": "你不能添加以'xmpp-upload.'开头的域名。这种名称是为YunoHost中集成的XMPP上传功能保留的。", - "domain_cannot_remove_main": "你不能删除'{domain}',因为它是主域,你首先需要用'yunohost domain main-domain -n '设置另一个域作为主域;这里是候选域的列表: {other_domains}", + "domain_cannot_remove_main_add_new_one": "您不能删除'{domain}',因为它是主域和您唯一的域,您需要先用'yunohost domain add '添加另一个域,然后用'yunohost domain main-domain -n '设置为主域,然后您可以用'yunohost domain remove {domain}'删除域", + "domain_cannot_add_xmpp_upload": "您不能添加以'xmpp-upload.'开头的域名。这种名称是为YunoHost中集成的XMPP上传功能保留的。", + "domain_cannot_remove_main": "您不能删除'{domain}',因为它是主域,您首先需要用'yunohost domain main-domain -n '设置另一个域作为主域;这里是候选域的列表: {other_domains}", "diagnosis_sshd_config_inconsistent_details": "请运行yunohost settings set security.ssh.port -v YOUR_SSH_PORT来定义SSH端口,并检查yunohost tools regen-conf ssh --dry-run --with-diffyunohost tools regen-conf ssh --force将您的配置重置为YunoHost建议。", - "diagnosis_http_bad_status_code": "它看起来像另一台机器(也许是你的互联网路由器)回答,而不是你的服务器。
1。这个问题最常见的原因是80端口(和443端口)没有正确转发到您的服务器
2.在更复杂的设置中:确保没有防火墙或反向代理的干扰。", - "diagnosis_http_timeout": "当试图从外部联系你的服务器时,出现了超时。它似乎是不可达的。
1. 这个问题最常见的原因是80端口(和443端口)没有正确转发到你的服务器
2.你还应该确保nginx服务正在运行
3.对于更复杂的设置:确保没有防火墙或反向代理的干扰。", + "diagnosis_http_bad_status_code": "它看起来像另一台机器(也许是您的互联网路由器)回答,而不是您的服务器。
1。这个问题最常见的原因是80端口(和443端口)没有正确转发到您的服务器
2.在更复杂的设置中:确保没有防火墙或反向代理的干扰。", + "diagnosis_http_timeout": "当试图从外部联系您的服务器时,出现了超时。它似乎是不可达的。
1. 这个问题最常见的原因是80端口(和443端口)没有正确转发到您的服务器
2.您还应该确保nginx服务正在运行
3.对于更复杂的设置:确保没有防火墙或反向代理的干扰。", "diagnosis_rootfstotalspace_critical": "根文件系统总共只有{space},这很令人担忧!您可能很快就会用完磁盘空间!建议根文件系统至少有16 GB。", "diagnosis_rootfstotalspace_warning": "根文件系统总共只有{space}。这可能没问题,但要小心,因为最终您可能很快会用完磁盘空间...建议根文件系统至少有16 GB。", - "diagnosis_regenconf_manually_modified_details": "如果你知道自己在做什么的话,这可能是可以的! YunoHost会自动停止更新这个文件... 但是请注意,YunoHost的升级可能包含重要的推荐变化。如果你想,你可以用yunohost tools regen-conf {category} --dry-run --with-diff检查差异,然后用yunohost tools regen-conf {category} --force强制设置为推荐配置", - "diagnosis_mail_fcrdns_nok_alternatives_6": "有些供应商不会让你配置你的反向DNS(或者他们的功能可能被破坏......)。如果你的反向DNS正确配置为IPv4,你可以尝试在发送邮件时禁用IPv6,方法是运yunohost settings set smtp.allow_ipv6 -v off。注意:这应视为最后一个解决方案因为这意味着你将无法从少数只使用IPv6的服务器发送或接收电子邮件。", - "diagnosis_mail_fcrdns_nok_alternatives_4": "有些供应商不会让你配置你的反向DNS(或者他们的功能可能被破坏......)。如果您因此而遇到问题,请考虑以下解决方案:
- 一些ISP提供了使用邮件服务器中转的选择,尽管这意味着中转将能够监视您的电子邮件流量。
- 一个有利于隐私的选择是使用VPN*与专用公共IP*来绕过这类限制。见https://yunohost.org/#/vpn_advantage
- 或者可以切换到另一个供应商", - "diagnosis_mail_ehlo_wrong_details": "远程诊断器在IPv{ipversion}中收到的EHLO与你的服务器的域名不同。
收到的EHLO: {wrong_ehlo}
预期的: {right_ehlo}
这个问题最常见的原因是端口25没有正确转发到你的服务器。另外,请确保没有防火墙或反向代理的干扰。", - "diagnosis_mail_ehlo_unreachable_details": "在IPv{ipversion}中无法打开与您服务器的25端口连接。它似乎是不可达的。
1. 这个问题最常见的原因是端口25没有正确转发到你的服务器
2.你还应该确保postfix服务正在运行。
3.在更复杂的设置中:确保没有防火墙或反向代理的干扰。", - "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "一些供应商不会让你解除对出站端口25的封锁,因为他们不关心网络中立性。
- 其中一些供应商提供了使用邮件服务器中继的替代方案,尽管这意味着中继将能够监视你的电子邮件流量。
- 一个有利于隐私的替代方案是使用VPN*,用一个专用的公共IP*绕过这种限制。见https://yunohost.org/#/vpn_advantage
- 你也可以考虑切换到一个更有利于网络中立的供应商", + "diagnosis_regenconf_manually_modified_details": "如果您知道自己在做什么的话,这可能是可以的! YunoHost会自动停止更新这个文件... 但是请注意,YunoHost的升级可能包含重要的推荐变化。如果您想,您可以用yunohost tools regen-conf {category} --dry-run --with-diff检查差异,然后用yunohost tools regen-conf {category} --force强制设置为推荐配置", + "diagnosis_mail_fcrdns_nok_alternatives_6": "有些供应商不会让您配置您的反向DNS(或者他们的功能可能被破坏......)。如果您的反向DNS正确配置为IPv4,您可以尝试在发送邮件时禁用IPv6,方法是运yunohost settings set smtp.allow_ipv6 -v off。注意:这应视为最后一个解决方案因为这意味着您将无法从少数只使用IPv6的服务器发送或接收电子邮件。", + "diagnosis_mail_fcrdns_nok_alternatives_4": "有些供应商不会让您配置您的反向DNS(或者他们的功能可能被破坏......)。如果您因此而遇到问题,请考虑以下解决方案:
- 一些ISP提供了使用邮件服务器中转的选择,尽管这意味着中转将能够监视您的电子邮件流量。
- 一个有利于隐私的选择是使用VPN*与专用公共IP*来绕过这类限制。见https://yunohost.org/#/vpn_advantage
- 或者可以切换到另一个供应商", + "diagnosis_mail_ehlo_wrong_details": "远程诊断器在IPv{ipversion}中收到的EHLO与您的服务器的域名不同。
收到的EHLO: {wrong_ehlo}
预期的: {right_ehlo}
这个问题最常见的原因是端口25没有正确转发到您的服务器。另外,请确保没有防火墙或反向代理的干扰。", + "diagnosis_mail_ehlo_unreachable_details": "在IPv{ipversion}中无法打开与您服务器的25端口连接。它似乎是不可达的。
1. 这个问题最常见的原因是端口25没有正确转发到您的服务器
2.您还应该确保postfix服务正在运行。
3.在更复杂的设置中:确保没有防火墙或反向代理的干扰。", + "diagnosis_mail_outgoing_port_25_blocked_relay_vpn": "一些供应商不会让您解除对出站端口25的封锁,因为他们不关心网络中立性。
- 其中一些供应商提供了使用邮件服务器中继的替代方案,尽管这意味着中继将能够监视您的电子邮件流量。
- 一个有利于隐私的替代方案是使用VPN*,用一个专用的公共IP*绕过这种限制。见https://yunohost.org/#/vpn_advantage
- 您也可以考虑切换到一个更有利于网络中立的供应商", "diagnosis_ram_ok": "系统在{total}中仍然有 {available} ({available_percent}%) RAM可用。", "diagnosis_ram_low": "系统有 {available} ({available_percent}%) RAM可用(共{total}个)可用。小心。", "diagnosis_ram_verylow": "系统只有 {available} ({available_percent}%) 内存可用! (在{total}中)", "diagnosis_diskusage_ok": "存储器{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中的服务日志(从命令行,你可以用yunohost service restart {service}yunohost service log {service})来做。", + "diagnosis_services_bad_status_tip": "您可以尝试重新启动服务,如果没有效果,可以看看webadmin中的服务日志(从命令行,您可以用yunohost service restart {service}yunohost service log {service})来做。", "diagnosis_dns_try_dyndns_update_force": "该域的DNS配置应由YunoHost自动管理,如果不是这种情况,您可以尝试使用 yunohost dyndns update --force强制进行更新。", "diagnosis_dns_point_to_doc": "如果您需要有关配置DNS记录的帮助,请查看 https://yunohost.org/dns_config 上的文档。", "diagnosis_dns_discrepancy": "以下DNS记录似乎未遵循建议的配置:
类型: {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