freedombox Debian release 21.0

-----BEGIN PGP SIGNATURE-----
 
 iQJKBAABCgA0FiEEfWrbdQ+RCFWJSEvmd8DHXntlCAgFAl/8/NkWHGp2YWxsZXJv
 eUBtYWlsYm94Lm9yZwAKCRB3wMdee2UICF2pD/91D2fMUtEJhn3ZclPbQPpBtiPq
 O5HlUaGS10776Dipi4z6s3+bI+WtzdqlKx1Tp5G4SFcVpSUzJZPwz9E3INN+qcvM
 5sorRxRmeMUlvxCsodG8YCd7Ub3VqkylkC9361FzhU6MBBZgwVG5w9jln3GrjsC9
 rmt3E9FPslswsJkBCKyn9MycZkF3JyzbVZfo5uZnSrX3tFhj3Brf197q1sO7cwe5
 QRpr4xlGeJYweltn4mvJm6CF65NhZXaoD0NBMSzE+mth/l7rqKg8YYdhgTIClJCn
 M/bq69OPe4hnfcIuk4mmJmi1/uAKttIpyKBh+vmAHphyallHNvqSvRmJ2KgHfh6c
 wYplnLO0+1pWBpR9YfaMmJBpHRZm6Hr8ABTkSF+SxQgSst7Xnzw0YQFdvPTv5qLe
 nY8gGCH2EjtjWHEULsB8UR3CpHiVcNmK7qMIj55sF7dwa6xROHWM3qdSlt5VJBrd
 7Rqr5n0vSU3CbmcpoExbyHD62smAOj7PLOi6zFwA5W7xTkZMNhTxGavirdtu+fSo
 U4j5TEMvrSS1LHpaMAdMXmjNv5F+FZgeyLyGc/GhbGvRScPcEibqXR2bCiuePrDJ
 SeK3JOOZf7REpIZ8YFV+XwapA1reiYx/rt4WkoE6tbpOc+rJY5irTNnTKzAxUv9d
 5Ex270lFAUxIy7juQg==
 =lUbE
 -----END PGP SIGNATURE-----
gpgsig -----BEGIN PGP SIGNATURE-----
 
 iQJKBAABCgA0FiEEfWrbdQ+RCFWJSEvmd8DHXntlCAgFAmAA7cwWHGp2YWxsZXJv
 eUBtYWlsYm94Lm9yZwAKCRB3wMdee2UICKy1D/sGlQjscBWwf+9S45aZ9GVtMj0t
 SyqWc7a0urZXrukqdxNYqLCNv6Q9oRpXPStrJ1ogexCJNxoCtoAERE0UfMxBre2b
 8Au/1n2h1zrH8qxQycDl/qRF8JUga9kJgdn/FwvDDZh5BN/q/rG84PYT7wwk3yod
 fJsi5KKE5BZ8qfCuZLwsQFyCg7Wq9C3qnrI7FeXpeACozAYwcJ1vCqhSV/2YomH0
 +oZm0o2rSVR7S7H4Gb/pVkf2Q1te5sS6Q+u31oJuJOt/ls2hniyndiV4fx8KrOon
 4L+i4mWMEej4bWIIfEmx+7W0R9PK4ARhw9kfAKNBJRNrPoLB9lQeUdcurrDFuJ2Z
 Xn8Gb54n1U3qPPvfgKURkMBpYtOJAITVKTmX6QruD0DEu9rcdLXBQuz32xu42zU6
 DavsY42VNuu5ZHhcqoVjuD+cruYzaKbhfJc/7y1JUktqbgIZfbvch52+RqANDPYc
 Dl6Hxvtcpg9eojSK7rQ8lbkvRmXAV1n70IL4287hjKN2cpICM2G1fKwGuU2Fdmxz
 WXSYABbAv1+AyMMVDwrHQJx/OFnAxouV/FQw0dX+GJUUe2q5XIS/D9py3ShF8jGo
 JTJPzI7eLxkBOeLXFQZLFDvgpMFvnLxxmXJp6EqscJmnbWyzFVMW3noEpqGNnmdV
 BKNA5LRDMoAn8fgTtg==
 =bO2i
 -----END PGP SIGNATURE-----

Merge tag 'v21.0' into debian/buster-backports

freedombox Debian release 21.0

Signed-off-by: James Valleroy <jvalleroy@mailbox.org>
This commit is contained in:
James Valleroy 2021-01-14 20:20:08 -05:00
commit b1782e4c13
199 changed files with 13295 additions and 12173 deletions

View File

@ -71,7 +71,13 @@ development environment inside a systemd-nspawn container.
host$ ./container up
```
4. SSH into the running container with the following command:
4. To run unit and functional tests for an app:
```bash
host$ ./container run-tests --pytest-args -v --include-functional --no-xvfb plinth/modules/{app name}
```
5. SSH into the running container with the following command:
```bash
host$ ./container ssh

View File

@ -12,7 +12,8 @@ import subprocess
import sys
from plinth.action_utils import (apt_hold, debconf_set_selections,
run_apt_command, service_restart)
run_apt_command, service_daemon_reload,
service_restart)
from plinth.modules.apache.components import check_url
from plinth.modules.upgrades import (BACKPORTS_SOURCES_LIST, SOURCES_LIST,
get_current_release, is_backports_current)
@ -76,6 +77,20 @@ Pin: release a=buster-backports
Pin-Priority: 500
'''
DIST_UPGRADE_SERVICE = '''
[Unit]
Description=Upgrade to new stable Debian release
[Service]
Type=oneshot
ExecStart=/usr/share/plinth/actions/upgrades dist-upgrade
KillMode=process
TimeoutSec=12hr
'''
DIST_UPGRADE_SERVICE_PATH = \
'/run/systemd/system/freedombox-dist-upgrade.service'
dist_upgrade_flag = pathlib.Path(
'/var/lib/freedombox/dist-upgrade-in-progress')
@ -100,13 +115,12 @@ def parse_arguments():
action='store_true',
help='Development mode')
dist_upgrade = subparsers.add_parser(
'dist-upgrade', help='Perform dist upgrade if possible')
dist_upgrade.add_argument('--develop', required=False, default=False,
action='store_true', help='Development mode')
dist_upgrade.add_argument('--test', required=False, default=False,
action='store_true',
help='Test dist-upgrade from stable to testing')
start_dist_upgrade = subparsers.add_parser(
'start-dist-upgrade', help='Check and start dist upgrade process')
start_dist_upgrade.add_argument(
'--test', required=False, default=False, action='store_true',
help='Test dist-upgrade from stable to testing')
subparsers.add_parser('dist-upgrade', help='Perform dist upgrade')
subparsers.required = True
return parser.parse_args()
@ -294,26 +308,24 @@ def _add_apt_preferences():
file_handle.write(APT_PREFERENCES_APPS)
def _check_and_dist_upgrade(develop=False, test_upgrade=False):
def _check_dist_upgrade(test_upgrade=False):
"""Check for new stable release. If there is one, and updates are
enabled, perform dist-upgrade.
enabled, return True.
If develop is True, check for possible upgrade from stable to testing.
If test_upgrade is True, also perform the upgrade to testing.
If test_upgrade is True, also check for upgrade to testing.
"""
if dist_upgrade_flag.exists():
print('Continuing previously interrupted dist-upgrade.')
_perform_dist_upgrade()
return
print('Found previously interrupted dist-upgrade.')
return True
release, dist = get_current_release()
if release in ['unstable', 'testing']:
print(f'System release is {release}. Skip checking for new stable '
'release.')
return
return False
check_dists = ['stable']
if develop:
if test_upgrade:
check_dists.append('testing')
codename = None
@ -336,27 +348,27 @@ def _check_and_dist_upgrade(develop=False, test_upgrade=False):
if not codename:
print('"Codename:" not found in release file.')
return
return False
if codename == dist:
print(f'{dist} is already the latest release.')
return
return False
if not _check_auto():
print('Automatic updates are not enabled.')
return
return False
if check_dist == 'testing' and not test_upgrade:
print(f'Skipping dist-upgrade to {check_dist} since --test is not '
'set.')
return
return False
output = subprocess.check_output(['df', '--output=avail,pcent', '/'])
output = output.decode().split('\n')[1].split()
free_space, free_percent = int(output[0]), int(output[1][:-1])
if free_space < 1000000 or free_percent < 10:
print('Not enough free space in /.')
return
return False
print(f'Upgrading from {dist} to {codename}...')
with open(SOURCES_LIST, 'r') as sources_list:
@ -378,7 +390,7 @@ def _check_and_dist_upgrade(develop=False, test_upgrade=False):
print('Dist upgrade in progress. Setting flag.')
dist_upgrade_flag.touch(mode=0o660)
_perform_dist_upgrade()
return True
def _perform_dist_upgrade():
@ -391,7 +403,7 @@ def _perform_dist_upgrade():
print('Upgrading base-files and unattended-upgrades...', flush=True)
run_apt_command(['install', 'base-files'])
run_apt_command(['install', 'unattended-upgrades'])
run_apt_command(['install', 'python3-systemd', 'unattended-upgrades'])
# Tell grub-pc to continue without installing grub again.
print('Set grub-pc to not require re-installing grub...', flush=True)
@ -413,27 +425,38 @@ def _perform_dist_upgrade():
'Holding packages with conffile prompts: ' +
', '.join(packages_with_prompts) + '...', flush=True)
with apt_hold(packages_with_prompts):
print('Running apt full-upgrade...', flush=True)
run_apt_command(['full-upgrade'])
print('Holding tt-rss package if available...', flush=True)
with apt_hold(['tt-rss'], ignore_errors=True):
print('Running apt full-upgrade...', flush=True)
run_apt_command(['full-upgrade'])
# If searx is installed, update search engines list.
if pathlib.Path('/etc/searx/settings.yml').exists():
print('Updating searx search engines list...', flush=True)
subprocess.run(['./searx', 'setup'], check=True)
subprocess.run(['/usr/share/plinth/actions/searx', 'setup'],
check=True)
print('Running apt autoremove...', flush=True)
run_apt_command(['autoremove'])
# Run unattended-upgrade once more to handle upgrading the
# freedombox package.
print('Running unattended-upgrade...', flush=True)
subprocess.run(['unattended-upgrade', '--verbose'])
# Restart FreedomBox service to ensure it is using the latest
# dependencies.
print('Restarting FreedomBox service...', flush=True)
service_restart('plinth')
# Update apt cache again to trigger force_upgrades.
print('Updating Apt cache...', flush=True)
run_apt_command(['update'])
print('Dist upgrade complete. Removing flag.', flush=True)
if dist_upgrade_flag.exists():
dist_upgrade_flag.unlink()
# FreedomBox Service may have tried to restart several times
# during the upgrade, but failed. It will stop trying after 5
# retries. Restart it once more to ensure it is running.
print('Restarting FreedomBox service...', flush=True)
service_restart('plinth')
def subcommand_setup(_):
"""Setup apt preferences."""
@ -450,14 +473,27 @@ def subcommand_activate_backports(arguments):
_check_and_backports_sources(arguments.develop)
def subcommand_dist_upgrade(arguments):
"""Perform major distribution upgrade.
Check if a new stable release is available, and perform dist-upgrade if
updates are enabled.
def subcommand_start_dist_upgrade(arguments):
"""Start dist upgrade process.
Check if a new stable release is available, and start dist-upgrade process
if updates are enabled.
"""
_check_and_dist_upgrade(arguments.develop, arguments.test)
if _check_dist_upgrade(arguments.test):
with open(DIST_UPGRADE_SERVICE_PATH, 'w') as service_file:
service_file.write(DIST_UPGRADE_SERVICE)
service_daemon_reload()
subprocess.Popen(['systemctl', 'start', 'freedombox-dist-upgrade'],
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, close_fds=True,
start_new_session=True)
def subcommand_dist_upgrade(_):
"""Perform major distribution upgrade.
"""
_perform_dist_upgrade()
def main():

127
container
View File

@ -161,10 +161,84 @@ sudo DEBIAN_FRONTEND=noninteractive apt-get install --no-upgrade --yes \
$(sudo -u plinth /freedombox/run --develop --list-dependencies)
sudo apt-mark unhold freedombox
# Install ncurses-term
sudo DEBIAN_FRONTEND=noninteractive apt-get install --yes ncurses-term
sudo DEBIAN_FRONTEND=noninteractive apt-get install --yes ncurses-term sshpass
echo 'alias freedombox-develop="sudo -u plinth /freedombox/run --develop"' \
>> /home/fbx/.bashrc
# Make some pytest related files and directories writable to the fbx user
sudo touch geckodriver.log
sudo chmod a+rw geckodriver.log
sudo mkdir -p .pytest_cache/
sudo chmod --recursive a+rw .pytest_cache/
'''
SETUP_AND_RUN_TESTS_SCRIPT = '''
BACKPORTS_SOURCES_LIST=/etc/apt/sources.list.d/freedombox2.list
LDAPSCRIPTS_CONF=/etc/ldapscripts/freedombox-ldapscripts.conf
# Remount /freedoombox to be up to date
echo "> In container: Remounting /freedombox"
mount -o remount /freedombox
# Activate backports if Debian stable
if [[ "{distribution}" == "stable" && ! -e $BACKPORTS_SOURCES_LIST ]]
then
echo "> In container: Enable backports"
/freedombox/actions/upgrades activate-backports
fi
echo "> In container: Upgrade packages"
apt-get update
apt-get -yq --with-new-pkgs upgrade
# Install requirements for tests if not already installed as root
if ! [[ -e /usr/local/bin/geckodriver && -e /usr/local/bin/pytest-bdd ]]
then
# sshpass for Debian Buster
apt-get install -yq --no-install-recommends sshpass
/freedombox/plinth/tests/functional/install.sh
fi
# Run the plinth server if functional tests are requested
if [[ "{pytest_command}" =~ "--include-functional" ]]
then
is_plinth_running=0
ps -ax -o cmd | grep -q "^sudo -u plinth /freedombox/run" && \
is_plinth_running=1
ps -ax -o cmd | grep -q "^/usr/bin/python3 /usr/bin/plinth" && \
is_plinth_running=1
if [[ $is_plinth_running -eq 1 ]]
then
echo "> In container: Plinth is already running"
else
echo -n "> In container: Starting plinth ... "
sudo -u plinth /freedombox/run --develop > plinth.log 2>&1 &
while ! grep -q "Setup thread finished" plinth.log
do
sleep 1
echo -n .
done
echo
fi
if [[ "{pytest_command}" =~ "--no-xvfb" ]]
then
# Use the X11 authority file from the fbx user to run GUI programs
xauth merge /home/fbx/.Xauthority
fi
fi
# Run pytest
cd /freedombox
export FREEDOMBOX_URL=https://localhost
export FREEDOMBOX_SSH_PORT=22
export FREEDOMBOX_SAMBA_PORT=445
{pytest_command}
# Make pytest cache files writable to the fbx user
chmod --recursive --silent a+rw .pytest_cache/
'''
logger = logging.getLogger(__name__)
@ -185,15 +259,32 @@ def parse_arguments():
subparser.add_argument(
'--distribution', choices=distributions, default='testing',
help='Distribution of the image to download and setup')
subparser.add_argument('--image-size', default='8G',
subparser.add_argument('--image-size', default='12G',
help='Disk image size to resize to after download')
# Print IP address
subparser = subparsers.add_parser(
'ip', help='Print the IP address of the container.')
subparser.add_argument(
'--distribution', choices=distributions, default='testing',
help='Distribution of the container to print IP address')
# ssh
subparser = subparsers.add_parser('ssh', help='SSH into the container')
subparser.add_argument('--distribution', choices=distributions,
default='testing',
help='Distribution of the container to SSH into')
# Run tests
subparser = subparsers.add_parser('run-tests',
help='Run tests in the container')
subparser.add_argument('--distribution', choices=distributions,
default='testing',
help='Distribution of the container to run tests')
subparser.add_argument(
'--pytest-args', nargs='...',
help='Additional arguments to pass to the pytest command')
# Stop
subparser = subparsers.add_parser('stop', help='Stop the container')
subparser.add_argument('--distribution', choices=distributions,
@ -739,6 +830,7 @@ Folder overlay : (host, read-only){project_folder}
-> (container)/freedombox
SSH easily : {script} ssh {options}
Run tests : {script} run-tests {options} [ --pytest-args ... ]
Run FreedomBox inside : freedombox-develop
Web access : https://{ip_address}/
@ -779,9 +871,10 @@ def _get_ssh_command(ip_address, distribution):
ip_address = f'{ip_address}%' + _get_interface_name(distribution)
return [
'ssh', '-i',
str(public_key), '-o', 'StrictHostKeyChecking=no', '-o',
'UserKnownHostsFile=/dev/null', f'fbx@{ip_address}'
'ssh', '-Y', '-C', '-t', '-i',
str(public_key), '-o', 'LogLevel=error', '-o',
'StrictHostKeyChecking=no', '-o', 'UserKnownHostsFile=/dev/null',
f'fbx@{ip_address}'
]
@ -827,6 +920,7 @@ def subcommand_up(arguments):
machine_name = f'fbx-{arguments.distribution}'
if _get_machine_status(machine_name):
logger.info('Container is already running')
_print_banner(arguments.distribution)
return
_verify_dependencies()
@ -839,6 +933,11 @@ def subcommand_up(arguments):
_print_banner(arguments.distribution)
def subcommand_ip(arguments):
"""Print the IP address of the container."""
print(_get_ip_address(arguments.distribution) or '')
def subcommand_ssh(arguments):
"""Open an SSH shell into the container."""
ip_address = _wait_for(lambda: _get_ip_address(arguments.distribution))
@ -847,6 +946,24 @@ def subcommand_ssh(arguments):
os.execlp('ssh', *command)
def subcommand_run_tests(arguments):
"""Run tests in the container."""
distribution = arguments.distribution
pytest_args = ' '.join(arguments.pytest_args or [])
ip_address = _wait_for(lambda: _get_ip_address(distribution))
ssh_command = _get_ssh_command(ip_address, distribution)
# Disable cache as root has no rights to overwrite files on /freedombox
pytest_command = f'py.test-3 {pytest_args}'
logger.info('Pytest command: %s', pytest_command)
test_script = SETUP_AND_RUN_TESTS_SCRIPT.format(
pytest_command=pytest_command, distribution=distribution)
setup_and_run_command = ['sudo', 'bash', '-c', f"'{test_script}'"]
command = ssh_command + setup_and_run_command
os.execlp('ssh', *command)
def subcommand_stop(arguments):
"""Stop the container."""
_stop(arguments.distribution)

View File

@ -46,7 +46,7 @@ RedirectMatch "^/freedombox" "/plinth"
##
<Location /plinth>
Header set Referrer-Policy 'same-origin'
Header set Content-Security-Policy "font-src 'self'; frame-src 'none'; img-src 'self'; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self'; worker-src 'self'; default-src 'self'; base-uri 'none'; sandbox allow-scripts allow-popups allow-forms allow-same-origin; form-action 'self'; frame-ancestors 'none'; block-all-mixed-content;"
Header set Content-Security-Policy "font-src 'self'; frame-src 'none'; img-src 'self'; manifest-src 'none'; media-src 'none'; object-src 'none'; script-src 'self'; style-src 'self'; worker-src 'self'; default-src 'self'; base-uri 'none'; sandbox allow-scripts allow-popups allow-forms allow-same-origin allow-downloads; form-action 'self'; frame-ancestors 'none'; block-all-mixed-content;"
Header set X-Content-Type-Options 'nosniff'
</Location>

77
debian/changelog vendored
View File

@ -1,3 +1,80 @@
freedombox (21.0) unstable; urgency=medium
[ Dietmar ]
* Translated using Weblate (German)
[ ikmaak ]
* Translated using Weblate (German)
* Translated using Weblate (Dutch)
* Translated using Weblate (Spanish)
* Translated using Weblate (French)
[ Burak Yavuz ]
* Translated using Weblate (Turkish)
[ Doma Gergő ]
* Translated using Weblate (Hungarian)
[ Veiko Aasa ]
* functional tests: Make tests compatible with pytest-bdd v4.0
* ejabberd: functional tests: Wait until the jsxc buddy list is loaded
* users: Skip action script tests if LDAP is not set up
* functional-tests: Fix installation errors in install.sh script
* dev-container: Add subcommand to run tests
* gitweb: tests: functional: Fix test failures in localized environment
* dev-container: 'up' command: Show banner also when container is already
running
* dev-container: Add command to print container IP address
* tests: functional: Improve creating users in tests
* gitweb: Add functional tests for git-access group
* plinth: Fix daemon is enabled check when service alias is provided
[ ullli ]
* mumble: Updated mumla and removed plumble from clients list
[ Johannes Keyser ]
* Translated using Weblate (German)
[ Sunil Mohan Adapa ]
* apache2: Allow downloads in openvpn and backups with latest browsers
* backups: Don't open a new window for downloading backups
* openvpn: Don't show running status on download profile button
* app: component: Add app_id and app properties
* app: Add locked flag
* backups: Add new component for backup and restore
* backups: Use the backup component in all apps
* doc: dev: Update documentation for using backup component
* app: info: Move client validation to info component
* doc: dev: Update documentation on calling clients validation
* doc: dev: Update the tutorial to reflect latest API/code
* radicale: Fix backup and restore of configuration
[ Michael Breidenbach ]
* Translated using Weblate (Swedish)
[ James Valleroy ]
* users: Avoid test error if ldapsearch is not available
* upgrades: Ensure freedombox package is upgraded during dist upgrade
* upgrades: Add service for dist upgrade
* upgrades: Install python3-systemd for unattended-upgrades
* upgrades: Don't allow needrestart to restart freedombox-dist-upgrade
* upgrades: Check before starting dist upgrade process
* upgrades: Write dist-upgrade service file in /run
* upgrades: Restart FreedomBox service at end of dist-upgrade
* upgrades: Use full path to searx action script
* upgrades: Hold tt-rss during dist upgrade, if available
* locale: Update translation strings
* doc: Fetch latest manual
[ Stanisław Stefan Krukowski ]
* Translated using Weblate (Polish)
[ Joseph Nuthalapati ]
* transmission: Show port forwarding information
* transmission: Update description
-- James Valleroy <jvalleroy@mailbox.org> Mon, 11 Jan 2021 19:57:44 -0500
freedombox (20.21~bpo10+1) buster-backports; urgency=medium
* Rebuild for buster-backports.

View File

@ -26,7 +26,7 @@ External References
#. `systemd System and Service Manager <https://www.freedesktop.org/wiki/Software/systemd/>`_
#. `Bootstrap - CSS Library <http://getbootstrap.com/css/>`_
#. `Bootstrap - CSS Library <http://getbootstrap.com/>`_
#. `FreedomBox User Manual <https://wiki.debian.org/FreedomBox/Manual>`_

View File

@ -6,17 +6,51 @@ App Module
These methods are optionally provided by the module in which an app is
implemented and FreedomBox calls/uses them if they are present.
<app-module>.init()
^^^^^^^^^^^^^^^^^^^
Optional. This method is called by FreedomBox soon after all the applications
are loaded. The ``init()`` call order guarantees that other applications that
this application depends on will be initialized before this application is
initialized.
<app-module>.depends
^^^^^^^^^^^^^^^^^^^^
Optional. This module property must contain a list of all apps that this
application depends on. The application is specified as string containing the
full module load path. For example, ``names``.
application depends on. The application is specified as string which is the
final part of the full module load path. For example, ``names``. Dependencies
are part of the :class:`~plinth.app.Info` component. Need for this attribute at
the module level will be removed in the future.
<app-module>.is_essential
^^^^^^^^^^^^^^^^^^^^^^^^^
Optional. If an app must be installed and configured by FreedomBox without user
intervention, this attribute must be set to True. This attribute is part of the
:class:`~plinth.app.Info` component. Need for this attribute at the module level
will be removed in the future.
<app-module>.version
^^^^^^^^^^^^^^^^^^^^
Optional. Version number of an app. Increasing the version number of an app
triggers the setup() logic allowing the app to run upgrade scripts. This
attribute is part of the :class:`~plinth.app.Info` component. Need for this
attribute at the module level will be removed in the future.
<app-module>.managed_packages
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Optional. This must contain the list of all packages that this app deals with.
This is mostly needed to enforce better security. This information may be moved
to a separate component in the future.
<app-module>.managed_services
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Optional. This must contain the list of all services that this app deals with.
This is mostly needed to enforce better security. This information is part of
the :class:`~plinth.daemon.Daemon` component. Need for this attribute at the
module level will be removed in the future.
<app-module>.managed_paths
^^^^^^^^^^^^^^^^^^^^^^^^^^^
Optional. This must contain the list of all file system paths that this app
deals with. This is mostly used by the
:class:`~plinth.modules.letsencrypt.components.LetsEncrypt` component to enforce
better security. This requirement may be removed in the future.

View File

@ -0,0 +1,7 @@
.. SPDX-License-Identifier: CC-BY-SA-4.0
Backups
^^^^^^^
.. autoclass:: plinth.modules.backups.components.BackupRestore
:members:

View File

@ -15,6 +15,7 @@ Components
domain
letsencrypt
staticfiles
backups
Base Classes
^^^^^^^^^^^^

View File

@ -5,7 +5,9 @@ Part 4: Components
Each :class:`~plinth.app.App` contains various :class:`~plinth.app.Component`
components that each provide one small functionality needed by the app. Each of
these components are instantiated and added to the app as children.
these components are instantiated and added to the app as children. The
:class:`~plinth.menu.Menu` object added in the previous step is one such
component.
Providing basic information about the app
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
@ -17,6 +19,8 @@ function normally.
from plinth import app as app_module
from . import manifest
class TransmissionApp(app_module.App):
...
@ -28,15 +32,45 @@ function normally.
icon_filename='transmission',
short_description=_('BitTorrent Web Client'),
description=description,
manual_page='Transmission', clients=clients)
manual_page='Transmission',
clients=manifest.clients,
donation_url='https://transmissionbt.com/donate/')
self.add(info)
The first argument is app_id that is same as the ID for the app. The version is
the version number for this app that must be incremented whenever setup() method
needs to be called again. name, icon_filename, short_description, description,
manual_page and clients provide information that is shown on the app's main
page. More information the parameters is available in :class:`~plinth.app.Info`
class documentation.
page. The donation_url encourages our users to contribute to upstream projects
in order ensure their long term sustainability. More information about the
parameters is available in :class:`~plinth.app.Info` class documentation.
The description of app should provide basic information on what the app is about
and how to use it. It is impractical, however, to explain everything about the
app in a few short paragraphs. So, we need to write a page about the app in the
FreedomBox manual. This page will be available to the users from within the
FreedomBox web interface. To make this happen, let us write a `manual page entry
<https://wiki.debian.org/FreedomBox/Manual/Transmission>`_ for our app in the
`FreedomBox Wiki <https://wiki.debian.org/FreedomBox/Manual>`_ and then provide
a link to it from app page.
It would be helpful to our users if we can show how they can use our app. If
there are desktop and mobile clients that can used to access our service, we
need to list them and present them. Let's add this information to
``manifest.py``.
.. code-block:: python3
clients = [{
'name': _('Transmission'),
'platforms': [{
'type': 'web',
'url': '/transmission'
}]
}]
Since our app is a simple web application with no clients needed, we just list
that.
Managing a daemon
^^^^^^^^^^^^^^^^^
@ -229,3 +263,49 @@ a later section of this tutorial. The next parameter specifies whether anonymous
users who are not logged into FreedomBox should be shown this shortcut. The
final parameter further restricts to which group of users this shortcut must be
shown.
Adding backup/restore functionality
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Each app in FreedomBox needs to provide the ability to backup and restore its
configuration and data. Apart from providing durability to users' data, this
allows the user to migrate from one machine to another. FreedomBox framework
provides a component for handling these operations. Create the
:class:`~plinth.modules.backups.components.BackupRestore` component during app
initialization.
In ``__init__.py``, add:
.. code-block:: python3
from plinth.modules.backups.components import BackupRestore
from . import manifest
class TransmissionApp(app_module.App):
...
def __init__(self):
...
backup_restore = BackupRestore('backup-restore-transmission',
**manifest.backup)
self.add(backup_restore)
In ``manifest.py``, add:
.. code-block:: python3
backup = {
'data': {
'directories': ['/var/lib/transmission-daemon/.config']
},
'secrets': {
'files': ['/etc/transmission-daemon/settings.json']
},
'services': ['transmission-daemon']
}
The data and secrets information specifies which list of files and directories
FreedomBox framework needs to backup and restore. The list of services specifies
which daemons should be stopped during the backup and restore process.

View File

@ -64,18 +64,21 @@ provide options to the user. Add the following to ``forms.py``.
from django import forms
class TransmissionForm(forms.Form): # pylint: disable=W0232
class TransmissionForm(DirectorySelectForm): # pylint: disable=W0232
"""Transmission configuration form"""
download_dir = forms.CharField(
label='Download directory',
help_text='Directory where downloads are saved. If you change the '
'default directory, ensure that the new directory exists '
'and is writable by "debian-transmission" user.')
This creates a Django form that shows a single option to set the download
directory for our Transmission app. This is how a regular Django form is built.
See :doc:`Django Forms documentation <django:topics/forms/index>` for more
information.
def __init__(self, *args, **kw):
validator = DirectoryValidator(username=SYSTEM_USER,
check_creatable=True)
super(TransmissionForm,
self).__init__(title=_('Download directory'),
default='/var/lib/transmission-daemon/downloads',
validator=validator, *args, **kw)
This uses a utility provided by the framework and creates a Django form that
shows a single option to set the download directory for our Transmission app.
This is similar to how a regular Django form is built. See :doc:`Django Forms
documentation <django:topics/forms/index>` for more information.
.. tip: Too many options
@ -100,8 +103,9 @@ the user submits it. Let us implement that in ``views.py``.
from .forms import TransmissionForm
class TransmissionAppView(views.AppView):
...
"""Serve configuration page."""
form_class = TransmissionForm
app_id = 'transmission'
def get_initial(self):
"""Get the current settings from Transmission server."""
@ -109,22 +113,18 @@ the user submits it. Let us implement that in ``views.py``.
configuration = actions.superuser_run('transmission',
['get-configuration'])
configuration = json.loads(configuration)
status.update({
key.translate(str.maketrans({
'-': '_'
})): value
for key, value in configuration.items()
})
status['storage_path'] = configuration['download-dir']
status['hostname'] = socket.gethostname()
return status
def form_valid(self, form):
"""Apply the changes submitted in the form."""
old_status = form.initial
new_status = form.cleaned_data
if old_status['download_dir'] != new_status['download_dir']:
if old_status['storage_path'] != new_status['storage_path']:
new_configuration = {
'download-dir': new_status['download_dir'],
'download-dir': new_status['storage_path'],
}
actions.superuser_run('transmission', ['merge-configuration'],

View File

@ -32,9 +32,11 @@ Coding standards
For readability and easy collaboration it is important to follow common coding
standards. FreedomBox uses the Python coding standards and uses the ``pylint``
and ``flake8`` tools to check if the there are any violations. Run these tools
on our application and fix any errors and warnings. Better yet, integrate these
tools into your favorite IDE for on-the-fly checking.
and ``flake8`` tools to check if the there are any violations. ``yapf`` and
``isort`` tools are used to automatically format the code to ensure that all
developers produce similarly formatted code. Run these tools on our application
and fix any errors and warnings. Better yet, integrate these tools into your
favorite IDE for on-the-fly checking.
For the most part, the code we have written so far, is already compliant with
the coding standards. This includes variable/method naming, indentation,

View File

@ -3,102 +3,6 @@
Part 7: Other Changes
---------------------
Showing information about app clients
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
It would be helpful to our users if we can show how they can use our app. If
there are desktop and mobile clients that can used to access our service, we
need to list them and present them. Let's add this information to
``manifest.py``.
.. code-block:: python3
from plinth.clients import validate
clients = validate([{
'name': _('Transmission'),
'platforms': [{
'type': 'web',
'url': '/transmission'
}]
}])
Since our app is a simple web application with no clients needed, we just list
that. We need to include this into the main app view. In ``__init__.py``, add:
.. code-block:: python3
from .manifest import clients
clients = clients
In ``views.py``, add:
.. code-block:: python3
from plinth.modules import transmission
class TransmissionAppView(views.AppView):
...
clients = transmission.clients
Writing a manual page
^^^^^^^^^^^^^^^^^^^^^
The description of app should provide basic information on what the app is about
and how to use it. It is impractical, however, to explain everything about the
app in a few short paragraphs. So, we need to write a page about the app in the
FreedomBox manual. This page will be available to the users from within the
FreedomBox web interface. To make this happen, let us write a `manual page entry
<https://wiki.debian.org/FreedomBox/Manual/Transmission>`_ for our app in the
`FreedomBox Wiki <https://wiki.debian.org/FreedomBox/Manual>`_ and then provide
a link to it from app page. In ``__init__.py``, add:
.. code-block:: python3
manual_page = 'Transmission'
Then, in ``views.py``, add:
.. code-block:: python3
from plinth.modules import transmission
class TransmissionAppView(views.AppView):
...
manual_page = transmission.manual_page
Adding backup/restore functionality
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Each app in FreedomBox needs to provide the ability to backup its configuration
and data. Apart from providing durability to users' data, this allows the user
to migrate from one machine to another. FreedomBox framework provides a simple
declarative mechanism to allow the app to be backed up and restored. In
``manifest.py``, add:
.. code-block:: python3
from plinth.modules.backups.api import validate as validate_backup
backup = validate_backup({
'data': {
'directories': ['/var/lib/transmission-daemon/.config']
},
'secrets': {
'files': ['/etc/transmission-daemon/settings.json']
},
'services': ['transmission-daemon']
})
The data and secrets information specifies which list of files and directories
FreedomBox framework needs to backup. The list of services specifies which
daemons should be stopped during the backup process. In ``__init__.py``, add:
.. code-block:: python3
from .manifest import backup
Creating diagnostics
^^^^^^^^^^^^^^^^^^^^
@ -174,16 +78,17 @@ the Django's localization methods to make that happen.
from django.utils.translation import ugettext_lazy as _
name = _('Transmission')
class TransmissionApp(app_module.App):
...
short_description = _('BitTorrent Web Client')
def __init__(self):
...
description = [
_('BitTorrent is a peer-to-peer file sharing protocol. '
'Transmission daemon handles Bitorrent file sharing. Note that '
'BitTorrent is not anonymous.'),
_('Access the web interface at <a href="/transmission">/transmission</a>.')
]
info = app_module.Info(...
name=_('Transmission'),
...
short_description=_('BitTorrent Web Client'),
...)
Notice that the app's name, description, etc. are wrapped in the ``_()`` method
call. This needs to be done for the rest of our app. We use the

View File

@ -67,7 +67,7 @@ Creating the App class
^^^^^^^^^^^^^^^^^^^^^^
In the FreedomBox framework, each app must be a class derived from the
:class:`plinth.app.App`. Let us to that in ``__init__.py``. We will fill up the
:class:`plinth.app.App`. Let us do that in ``__init__.py``. We will fill up the
class later.
.. code-block:: python3

View File

@ -36,26 +36,15 @@ a link in FreedomBox web interface. Let us add a link in the apps list. In
from plinth.menu import main_menu
name = 'Transmission'
short_description = 'BitTorrent Web Client'
description = [
'BitTorrent is a peer-to-peer file sharing protocol. '
'Transmission daemon handles Bitorrent file sharing. Note that '
'BitTorrent is not anonymous.',
'Access the web interface at <a href="/transmission">/transmission</a>.'
]
class TransmissionApp(app_module.App):
...
def __init__(self):
...
menu_item = menu.Menu('menu-transmission', name, short_description,
'transmission', 'transmission:index',
parent_url_name='apps')
menu_item = menu.Menu('menu-transmission', 'Transmission',
'BitTorrrent Web Client', 'transmission',
'transmission:index', parent_url_name='apps')
self.add(menu_item)
What this does is add a menu item component into the our app. In FreedomBox

View File

@ -1,6 +1,8 @@
#language en
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: English - [[es/FreedomBox/Manual/Calibre|Español]] -~
##TAG:TRANSLATION-HEADER-START
~- [[FreedomBox/Manual/Calibre|English]] - [[es/FreedomBox/Manual/Calibre|Español]] - [[DebianWiki/EditorGuide#translation|(+)]]-~
##TAG:TRANSLATION-HEADER-END
<<TableOfContents()>>
@ -31,7 +33,7 @@ calibre can be accessed after installation through the web client at {{{https://
=== External links ===
* Official website <<BR>> https://calibre-ebook.com/
* Official website <<BR>> https://calibre-ebook.com
## END_INCLUDE

View File

@ -1,6 +1,8 @@
#language en
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: English - [[es/FreedomBox/Manual/Coturn|Español]] -~
##TAG:TRANSLATION-HEADER-START
~- [[FreedomBox/Manual/Coturn|English]] - [[es/FreedomBox/Manual/Coturn|Español]] - [[DebianWiki/EditorGuide#translation|(+)]] -~
##TAG:TRANSLATION-HEADER-END
<<TableOfContents()>>
@ -11,7 +13,7 @@
'''Available since''': version 20.8
Coturn is a server to facilitate audio/video calls and conferences by providing an implementation of TURN and STUN protocols. WebRTC, SIP and other communication servers can use it to establish a call between parties who are otherwise unable connect to each other.
Coturn is a server to facilitate audio/video calls and conferences by providing an implementation of [[https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT|TURN]] and [[https://en.wikipedia.org/wiki/STUN|STUN]] protocols. WebRTC, SIP and other communication servers can use it to establish a call between parties who are otherwise unable connect to each other.
It is not meant to be used directly by users. Servers such as Matrix Synapse need to be configured with the details provided on the Coturn app page. Apart from Matrix Synapse, Jitsi, Ejabberd, Nextcloud Talk, etc. can use Coturn server for audio/video calls and conferences. There is no need for the servers to be running on the same machine as !FreedomBox and external servers can use Coturn running on !FreedomBox.
@ -21,9 +23,9 @@ Coturn is configured in !FreedomBox as an advanced app. This means that you need
When making an audio/video call, it is best to route the media streams between two peers directly. This will give the best possible latency (better signal quality) and avoid depending on a centralized server (privacy). It scales well because a simple chat server can host thousands of calls without involving the server in any way other than to setup the call. However, this approach does not work most of the time to due to how networks are configured. Most peers on the network do not have a unique IP address allocated to them. They work hidden behind a network device that performs "Network Address Translation" (NAT) for them. This means that the two peers have no way of reaching each other.
To address this problem, a simple technique known as STUN was introduced. With the help of a third party STUN server, the peers can trick the NAT devices, to carry the traffic between the two peers. Unfortunately, this trick only works about 80% of the time. So, if STUN fails, peers have no choice but to route their traffic through an intermediary server called TURN server. All the mechanism of trying out STUN first and then falling back to TURN is described in a protocol known as ICE.
To address this problem, a simple technique known as STUN was introduced. With the help of a third party STUN server, the peers can trick the NAT devices, to carry the traffic between the two peers. Unfortunately, this trick only works about 80% of the time. So, if STUN fails, peers have no choice but to route their traffic through an intermediary server called TURN server. All the mechanism of trying out STUN first and then falling back to TURN is described in a protocol known as [[https://en.wikipedia.org/wiki/Interactive_Connectivity_Establishment|ICE]].
On !FreedomBox, Coturn provides both STUN and TURN servers. Both services are provided over TCP as well as UDP. They are provided on unencrypted as well as encrypted channels (with have a higher chance of success). Since STUN servers are very inexpensive and don't consume a lot of server resources, there is no authentication needed to use them. TURN servers on the other hand need authentication. This authentication is highly simplified and does not require maintaining a database of users. A server such as matrix-synapse which is about to setup an audio/video call between two peers will generate a username and password using a shared secret. When the peers use the TURN server, they will be validated using these credentials because the TURN server also knows the same secret.
On !FreedomBox, Coturn provides both STUN and TURN servers. Both services are provided over TCP as well as UDP. They are provided on unencrypted as well as encrypted channels (which have a higher chance of success). Since STUN servers are very inexpensive and don't consume a lot of server resources, there is no authentication needed to use them. TURN servers on the other hand need authentication. This authentication is highly simplified and does not require maintaining a database of users. A server such as matrix-synapse which is about to setup an audio/video call between two peers will generate a username and password using a shared secret. When the peers use the TURN server, they will be validated using these credentials because the TURN server also knows the same secret.
In summary, a communication server needs to know the URLs of the STUN/TURN servers along with a shared authentication secret for TURN. After that, during audio/video call setup, they will correctly guide the peers to use STUN/TURN servers. Coturn app in !FreedomBox provides exactly this information. This information can be used to configure a communication server irrespective of whether it is running on the same !FreedomBox or on another server.

View File

@ -1,6 +1,8 @@
#language en
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: English - [[es/FreedomBox/Manual/Deluge|Español]] -~
##TAG:TRANSLATION-HEADER-START
~- [[de/FreedomBox/Manual/Deluge|Deutsch]] - [[FreedomBox/Manual/Deluge|English]] - [[es/FreedomBox/Manual/Deluge|Español]] - [[DebianWiki/EditorGuide#translation|(+)]]-~
##TAG:TRANSLATION-HEADER-END
<<TableOfContents()>>

View File

@ -1,7 +1,7 @@
#language en
##TAG:TRANSLATION-HEADER-START
~-[[FreedomBox/Manual/GettingHelp|English]] - [[es/FreedomBox/Manual/GettingHelp|Español]] - [[fr/FreedomBox/Manual/GettingHelp|Français]] - [[DebianWiki/EditorGuide#translation|(+)]]-~
~-[[de/FreedomBox/Manual/GettingHelp|Deutsch]] - [[FreedomBox/Manual/GettingHelp|English]] - [[es/FreedomBox/Manual/GettingHelp|Español]] - [[fr/FreedomBox/Manual/GettingHelp|Français]] - [[DebianWiki/EditorGuide#translation|(+)]]-~
##TAG:TRANSLATION-HEADER-END
## BEGIN_INCLUDE

View File

@ -1,6 +1,8 @@
#language en
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: English - [[es/FreedomBox/Manual/GitWeb|Español]] -~
##TAG:TRANSLATION-HEADER-START
~- [[FreedomBox/Manual/GitWeb|English]] - [[es/FreedomBox/Manual/GitWeb|Español]] - [[DebianWiki/EditorGuide#translation|(+)]] -~
##TAG:TRANSLATION-HEADER-END
<<TableOfContents()>>

View File

@ -6,7 +6,7 @@
=== Using ===
* !FreedomBox Service comes installed with all !FreedomBox images. You can [[FreedomBox/Download|download]] !FreedomBox images and run on any of the supported hardware. Then, you can access !FreedomBox Service by visiting the URL [[http://freedombox/plinth]] or [[https://freedombox.local/plinth]].
* !FreedomBox Service comes installed with all !FreedomBox images. You can [[FreedomBox/Download|download]] !FreedomBox images and run on any of the supported hardware. Then, to access !FreedomBox interface see [[FreedomBox/Manual/QuickStart|quick start]] instructions.
* If you are on a Debian box, you may install !FreedomBox Service from Debian package archive. Currently, only Buster (stable), Bullseye (testing), and Sid (unstable) are supported. To install !FreedomBox Service run:

View File

@ -10,6 +10,44 @@ For more technical details, see the [[https://salsa.debian.org/freedombox-team/f
The following are the release notes for each !FreedomBox version.
== FreedomBox 21.0 (2021-01-11) ==
=== Highlights ===
* apache2: Allow downloads in openvpn and backups with latest browsers
=== Other Changes ===
* locale: Update translations for Dutch, French, German, Hungarian, Polish, Spanish, Swedish, Turkish
* app: Add locked flag
* app: component: Add app_id and app properties
* app: info: Move client validation to info component
* backups: Add new component for backup and restore
* backups: Don't open a new window for downloading backups
* dev-container: 'up' command: Show banner also when container is already running
* dev-container: Add command to print container IP address
* dev-container: Add subcommand to run tests
* doc: dev: Update the tutorial to reflect latest API/code
* ejabberd: functional tests: Wait until the jsxc buddy list is loaded
* functional tests: Make tests compatible with pytest-bdd v4.0
* functional-tests: Fix installation errors in install.sh script
* gitweb: Add functional tests for git-access group
* gitweb: tests: functional: Fix test failures in localized environment
* mumble: Updated mumla and removed plumble from clients list
* openvpn: Don't show running status on download profile button
* plinth: Fix daemon is enabled check when service alias is provided
* radicale: Fix backup and restore of configuration
* tests: functional: Improve creating users in tests
* transmission: Show port forwarding information
* transmission: Update description
* upgrades: Add service for dist upgrade
* upgrades: Ensure freedombox package is upgraded during dist upgrade
* upgrades: Hold tt-rss during dist upgrade, if available
* upgrades: Install python3-systemd for unattended-upgrades
* upgrades: Restart !FreedomBox service at end of dist-upgrade
* upgrades: Use full path to searx action script
* users: Skip action script tests if LDAP is not set up
== FreedomBox 20.21 (2020-12-28) ==
=== Highlights ===

View File

@ -1,6 +1,8 @@
#language en
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: English - [[es/FreedomBox/Manual/Tor|Español]] -~
##TAG:TRANSLATION-HEADER-START
~- [[de/FreedomBox/Manual/Tor|Deutsch]] - [[FreedomBox/Manual/Tor|English]] - [[es/FreedomBox/Manual/Tor|Español]] - [[DebianWiki/EditorGuide#translation|(+)]]-~
##TAG:TRANSLATION-HEADER-END
<<TableOfContents()>>

View File

@ -1,7 +1,10 @@
## page was renamed from FreedomBox/Manual/XMPP
#language en
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: English - [[es/FreedomBox/Manual/ejabberd|Español]] -~
##TAG:TRANSLATION-HEADER-START
~- [[FreedomBox/Manual/ejabberd|English]] - [[es/FreedomBox/Manual/ejabberd|Español]] - [[DebianWiki/EditorGuide#translation|(+)]]-~
##TAG:TRANSLATION-HEADER-END
<<TableOfContents()>>

View File

@ -1,6 +1,6 @@
#language es
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: [[FreedomBox/Manual/Calibre|English]] - Español -~
<<Include(FreedomBox/Manual/Calibre, ,from="^##TAG:TRANSLATION-HEADER-START",to="^##TAG:TRANSLATION-HEADER-END")>>
<<TableOfContents()>>

View File

@ -1,6 +1,6 @@
#language es
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: [[FreedomBox/Manual/Coturn|English]] - Español -~
<<Include(FreedomBox/Manual/Coturn, ,from="^##TAG:TRANSLATION-HEADER-START",to="^##TAG:TRANSLATION-HEADER-END")>>
<<TableOfContents()>>

View File

@ -1,6 +1,6 @@
#language es
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: [[FreedomBox/Manual/Deluge|English]] - Español -~
<<Include(FreedomBox/Manual/Deluge, ,from="^##TAG:TRANSLATION-HEADER-START",to="^##TAG:TRANSLATION-HEADER-END")>>
<<TableOfContents()>>

View File

@ -1,6 +1,6 @@
#language es
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: [[FreedomBox/Manual/GitWeb|English]] - Español -~
<<Include(FreedomBox/Manual/GitWeb, ,from="^##TAG:TRANSLATION-HEADER-START",to="^##TAG:TRANSLATION-HEADER-END")>>
<<TableOfContents()>>

View File

@ -10,6 +10,44 @@ For more technical details, see the [[https://salsa.debian.org/freedombox-team/f
The following are the release notes for each !FreedomBox version.
== FreedomBox 21.0 (2021-01-11) ==
=== Highlights ===
* apache2: Allow downloads in openvpn and backups with latest browsers
=== Other Changes ===
* locale: Update translations for Dutch, French, German, Hungarian, Polish, Spanish, Swedish, Turkish
* app: Add locked flag
* app: component: Add app_id and app properties
* app: info: Move client validation to info component
* backups: Add new component for backup and restore
* backups: Don't open a new window for downloading backups
* dev-container: 'up' command: Show banner also when container is already running
* dev-container: Add command to print container IP address
* dev-container: Add subcommand to run tests
* doc: dev: Update the tutorial to reflect latest API/code
* ejabberd: functional tests: Wait until the jsxc buddy list is loaded
* functional tests: Make tests compatible with pytest-bdd v4.0
* functional-tests: Fix installation errors in install.sh script
* gitweb: Add functional tests for git-access group
* gitweb: tests: functional: Fix test failures in localized environment
* mumble: Updated mumla and removed plumble from clients list
* openvpn: Don't show running status on download profile button
* plinth: Fix daemon is enabled check when service alias is provided
* radicale: Fix backup and restore of configuration
* tests: functional: Improve creating users in tests
* transmission: Show port forwarding information
* transmission: Update description
* upgrades: Add service for dist upgrade
* upgrades: Ensure freedombox package is upgraded during dist upgrade
* upgrades: Hold tt-rss during dist upgrade, if available
* upgrades: Install python3-systemd for unattended-upgrades
* upgrades: Restart !FreedomBox service at end of dist-upgrade
* upgrades: Use full path to searx action script
* users: Skip action script tests if LDAP is not set up
== FreedomBox 20.21 (2020-12-28) ==
=== Highlights ===

View File

@ -1,6 +1,6 @@
#language es
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: [[FreedomBox/Manual/Tor|English]] - Español -~
<<Include(FreedomBox/Manual/Tor, ,from="^##TAG:TRANSLATION-HEADER-START",to="^##TAG:TRANSLATION-HEADER-END")>>
<<TableOfContents()>>

View File

@ -1,7 +1,7 @@
## page was renamed from FreedomBox/Manual/XMPP
#language es
~- [[DebianWiki/EditorGuide#translation|Translation(s)]]: [[FreedomBox/Manual/ejabberd|English]] - Español -~
<<Include(FreedomBox/Manual/ejabberd, ,from="^##TAG:TRANSLATION-HEADER-START",to="^##TAG:TRANSLATION-HEADER-END")>>
<<TableOfContents()>>

View File

@ -3,4 +3,4 @@
Package init file.
"""
__version__ = '20.21'
__version__ = '21.0'

View File

@ -413,7 +413,7 @@ def run_apt_command(arguments):
@contextmanager
def apt_hold(packages=None):
def apt_hold(packages=None, ignore_errors=False):
"""Prevent packages from being removed during apt operations."""
if not packages:
packages = ['freedombox']
@ -421,7 +421,8 @@ def apt_hold(packages=None):
current_hold = subprocess.check_output(['apt-mark', 'showhold'] + packages)
try:
yield current_hold or subprocess.run(['apt-mark', 'hold'] + packages,
check=True)
check=not ignore_errors)
finally:
if not current_hold:
subprocess.run(['apt-mark', 'unhold'] + packages, check=True)
subprocess.run(['apt-mark', 'unhold'] + packages,
check=not ignore_errors)

View File

@ -5,6 +5,8 @@ Base class for all Freedombox applications.
import collections
from . import clients as clients_module
class App:
"""Implement common functionality for an app.
@ -23,12 +25,19 @@ class App:
the user. Enable/disable button for this app will not be shown. Default
value is True, so the app can be disabled.
'locked' is a boolean indicating whether the user can perform operations on
the app. This flag is currently set during backup and restore operations
but UI changes are currently not implemented.
"""
app_id = None
can_be_disabled = True
locked = False # Whether user interaction with the app is allowed.
# XXX: Lockdown the application UI by implementing a middleware
_all_apps = collections.OrderedDict()
def __init__(self):
@ -53,12 +62,14 @@ class App:
def add(self, component):
"""Add a component to an app."""
component.app_id = self.app_id
self.components[component.component_id] = component
return self
def remove(self, component_id):
"""Remove a component from the app."""
component = self.components[component_id]
component.app_id = None
del self.components[component_id]
return component
@ -167,7 +178,13 @@ class App:
class Component:
"""Interface for an app component."""
"""Interface for an app component.
`app_id` is a string which is set to the value of the application's app_id
to which this component belongs. It is set when the component is added to
an app. When the component is removed from an app, it set to None.
"""
is_leader = False
@ -177,6 +194,16 @@ class Component:
raise ValueError('Invalid component ID')
self.component_id = component_id
self.app_id = None
@property
def app(self):
"""Return the app this component is part of.
Raises KeyError if this component is not part of any app.
"""
return App.get(self.app_id)
def enable(self):
"""Run operations to enable the component."""
@ -348,3 +375,5 @@ class Info(FollowerComponent):
self.manual_page = manual_page
self.clients = clients
self.donation_url = donation_url
if clients:
clients_module.validate(clients)

View File

@ -34,12 +34,12 @@ class Daemon(app.LeaderComponent):
is renamed, the new unit file usually contains an Alias= setting in
[Install] section with value of old unit name. When the unit is
enabled, a symlink with the name of the alias is created. All
operations such as is-enabled, is-running and disable work with the
alias along with the primary unit name. However, for the case of
enabling the unit file, the alias does not work. To be able to provide
management for multiple versions of the unit file with different names,
specify an alias. Both the names are taken into consideration when
enabling the unit file.
operations such as is-running and disable work with the alias along
with the primary unit name. However, for the case of enabling the unit
file or checking its enabled status, the alias does not work. To be
able to provide management for multiple versions of the unit file with
different names, specify an alias. Both the names are taken into
consideration when enabling the unit file.
"""
super().__init__(component_id)
@ -51,6 +51,19 @@ class Daemon(app.LeaderComponent):
def is_enabled(self):
"""Return if the daemon/unit is enabled."""
if self.alias:
# XXX: Handling alias should not be done here. service_is_enabled()
# should return True even for an alias. Currently, in addition to
# return code we are also checking the printed value. This makes
# the implementation less future-proof as new values could printed
# by the command. A fixed systemd bug
# https://github.com/systemd/systemd/issues/18134 also currently
# gives incorrect exit code for 'alias' case. See:
# https://salsa.debian.org/freedombox-team/freedombox/-/merge_requests/1980
if action_utils.service_is_enabled(self.alias,
strict_check=self.strict_check):
return True
return action_utils.service_is_enabled(self.unit,
strict_check=self.strict_check)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -9,13 +9,14 @@ from plinth import actions
from plinth import app as app_module
from plinth import cfg, menu
from plinth.daemon import Daemon
from plinth.modules.backups.components import BackupRestore
from plinth.modules.config import get_hostname
from plinth.modules.firewall.components import Firewall
from plinth.modules.names.components import DomainType
from plinth.signals import domain_added, domain_removed, post_hostname_change
from plinth.utils import format_lazy
from .manifest import backup # noqa, pylint: disable=unused-import
from . import manifest
# pylint: disable=C0103
@ -76,6 +77,10 @@ class AvahiApp(app_module.App):
daemon = Daemon('daemon-avahi', managed_services[0])
self.add(daemon)
backup_restore = BackupRestore('backup-restore-avahi',
**manifest.backup)
self.add(backup_restore)
if self.is_enabled():
domain_added.send_robust(sender='avahi',
domain_type='domain-type-local',

View File

@ -3,10 +3,8 @@
Application manifest for avahi.
"""
from plinth.modules.backups.api import validate as validate_backup
# Services that intend to make themselves discoverable will drop files into
# /etc/avahi/services. Currently, we don't intend to make that customizable.
# There is no necessity for backup and restore. This manifest will ensure that
# avahi enable/disable setting is preserved.
backup = validate_backup({})
backup = {}

View File

@ -75,10 +75,10 @@ def _backup_handler(packet, encryption_passphrase=None):
get_valid_filename(packet.path) + '.json')
manifests = {
'apps': [{
'name': app.name,
'version': app.app.app.info.version,
'backup': app.manifest
} for app in packet.apps]
'name': component.app.app_id,
'version': component.app.info.version,
'backup': component.manifest
} for component in packet.components]
}
with open(manifest_path, 'w') as manifest_file:
json.dump(manifests, manifest_file)
@ -124,9 +124,9 @@ def restore_archive_handler(packet, encryption_passphrase=None):
actions.superuser_run('backups', arguments, input=locations_data.encode())
def restore_from_upload(path, apps=None):
def restore_from_upload(path, app_ids=None):
"""Restore files from an uploaded .tar.gz backup file"""
api.restore_apps(_restore_exported_archive_handler, app_names=apps,
api.restore_apps(_restore_exported_archive_handler, app_ids=app_ids,
create_subvolume=False, backup_file=path)

View File

@ -10,79 +10,38 @@ TODO:
- Implement unit tests.
"""
import importlib
import logging
from plinth import actions, action_utils, module_loader, setup
from plinth import action_utils, actions
from plinth import app as app_module
from plinth import setup
from .components import BackupRestore
logger = logging.getLogger(__name__)
def validate(backup):
"""Validate the backup' information schema."""
assert isinstance(backup, dict)
if 'config' in backup:
assert isinstance(backup['config'], dict)
_validate_directories_and_files(backup['config'])
if 'data' in backup:
assert isinstance(backup['data'], dict)
_validate_directories_and_files(backup['data'])
if 'secrets' in backup:
assert isinstance(backup['secrets'], dict)
_validate_directories_and_files(backup['secrets'])
if 'services' in backup:
assert isinstance(backup['services'], list)
for service in backup['services']:
assert isinstance(service, (str, dict))
if isinstance(service, dict):
_validate_service(service)
return backup
def _validate_directories_and_files(section):
"""Validate directories and files keys in a section."""
if 'directories' in section:
assert isinstance(section['directories'], list)
for directory in section['directories']:
assert isinstance(directory, str)
if 'files' in section:
assert isinstance(section['files'], list)
for file_path in section['files']:
assert isinstance(file_path, str)
def _validate_service(service):
"""Validate a service manifest provided as a dictionary."""
assert isinstance(service['name'], str)
assert isinstance(service['type'], str)
assert service['type'] in ('apache', 'uwsgi', 'system')
if service['type'] == 'apache':
assert service['kind'] in ('config', 'site', 'module')
class BackupError:
"""Represent an backup/restore operation error."""
def __init__(self, error_type, app, hook=None):
def __init__(self, error_type, component, hook=None):
"""Initialize the error object."""
self.error_type = error_type
self.app = app
self.component = component
self.hook = hook
def __eq__(self, other_error):
"""Compare to error objects."""
return (self.error_type == other_error.error_type
and self.app == other_error.app
and self.component == other_error.component
and self.hook == other_error.hook)
class Packet:
"""Information passed to a handlers for backup/restore operations."""
def __init__(self, operation, scope, root, apps=None, path=None):
def __init__(self, operation, scope, root, components=None, path=None):
"""Initialize the packet.
operation is either 'backup' or 'restore.
@ -101,7 +60,7 @@ class Packet:
self.operation = operation
self.scope = scope
self.root = root
self.apps = apps
self.components = components
self.path = path
self.errors = []
@ -112,11 +71,11 @@ class Packet:
def _process_manifests(self):
"""Look at manifests and fill up the list of directories/files."""
for app in self.apps:
for component in self.components:
for section in ['config', 'data', 'secrets']:
self.directories += app.manifest.get(section, {}).get(
'directories', [])
self.files += app.manifest.get(section, {}).get('files', [])
section = getattr(component, section)
self.directories += section.get('directories', [])
self.files += section.get('files', [])
def backup_full(backup_handler, path=None):
@ -146,25 +105,25 @@ def restore_full(restore_handler):
_switch_to_subvolume(subvolume)
def backup_apps(backup_handler, path, app_names=None,
def backup_apps(backup_handler, path, app_ids=None,
encryption_passphrase=None):
"""Backup data belonging to a set of applications."""
if not app_names:
apps = get_all_apps_for_backup()
if not app_ids:
components = get_all_components_for_backup()
else:
apps = get_apps_in_order(app_names)
components = get_components_in_order(app_ids)
if _is_snapshot_available():
snapshot = _take_snapshot()
backup_root = snapshot['mount_path']
snapshotted = True
else:
_lockdown_apps(apps, lockdown=True)
original_state = _shutdown_services(apps)
_lockdown_apps(components, lockdown=True)
original_state = _shutdown_services(components)
backup_root = '/'
snapshotted = False
packet = Packet('backup', 'apps', backup_root, apps, path)
packet = Packet('backup', 'apps', backup_root, components, path)
_run_operation(backup_handler, packet,
encryption_passphrase=encryption_passphrase)
@ -172,29 +131,29 @@ def backup_apps(backup_handler, path, app_names=None,
_delete_snapshot(snapshot)
else:
_restore_services(original_state)
_lockdown_apps(apps, lockdown=False)
_lockdown_apps(components, lockdown=False)
def restore_apps(restore_handler, app_names=None, create_subvolume=True,
def restore_apps(restore_handler, app_ids=None, create_subvolume=True,
backup_file=None, encryption_passphrase=None):
"""Restore data belonging to a set of applications."""
if not app_names:
apps = get_all_apps_for_backup()
if not app_ids:
components = get_all_components_for_backup()
else:
apps = get_apps_in_order(app_names)
components = get_components_in_order(app_ids)
_install_apps_before_restore(apps)
_install_apps_before_restore(components)
if _is_snapshot_available() and create_subvolume:
subvolume = _create_subvolume(empty=False)
restore_root = subvolume['mount_path']
else:
_lockdown_apps(apps, lockdown=True)
original_state = _shutdown_services(apps)
_lockdown_apps(components, lockdown=True)
original_state = _shutdown_services(components)
restore_root = '/'
subvolume = False
packet = Packet('restore', 'apps', restore_root, apps, backup_file)
packet = Packet('restore', 'apps', restore_root, components, backup_file)
_run_operation(restore_handler, packet,
encryption_passphrase=encryption_passphrase)
@ -202,10 +161,10 @@ def restore_apps(restore_handler, app_names=None, create_subvolume=True,
_switch_to_subvolume(subvolume)
else:
_restore_services(original_state)
_lockdown_apps(apps, lockdown=False)
_lockdown_apps(components, lockdown=False)
def _install_apps_before_restore(apps):
def _install_apps_before_restore(components):
"""Install/upgrade apps needed before restoring a backup.
Upgrading apps to latest version before backups reduces the chance of newer
@ -213,92 +172,57 @@ def _install_apps_before_restore(apps):
"""
modules_to_setup = []
for backup_app in apps:
if backup_app.app.setup_helper.get_state() in ('needs-setup',
'needs-update'):
modules_to_setup.append(backup_app.name)
for component in components:
module = importlib.import_module(component.app.__class__.__module__)
if module.setup_helper.get_state() in ('needs-setup', 'needs-update'):
modules_to_setup.append(component.app.app_id)
setup.run_setup_on_modules(modules_to_setup)
class BackupApp:
"""A application that can be backed up and its manifest."""
def __init__(self, name, app):
"""Initialize object and load manfiest."""
self.name = name
self.app = app
def _get_backup_restore_component(app):
"""Return the backup/restore component of the app."""
for component in app.components.values():
if isinstance(component, BackupRestore):
return component
# Has no backup related meta data
raise TypeError
def get_all_components_for_backup():
"""Return a list of all components that can be backed up."""
components = []
for app_ in app_module.App.list():
try:
self.manifest = app.backup
except AttributeError:
raise TypeError
self.has_data = bool(app.backup)
def __eq__(self, other_app):
"""Check if this app is same as another."""
return self.name == other_app.name and \
self.app == other_app.app and \
self.manifest == other_app.manifest and \
self.has_data == other_app.has_data
def is_installed(self):
"""Return whether app is installed.
Return true even if the app needs update.
"""
return self.app.setup_helper.get_state() != 'needs-setup'
def run_hook(self, hook, packet):
"""Run a hook inside an application."""
if not hasattr(self.app, hook):
return
try:
getattr(self.app, hook)(packet)
except Exception as exception:
logger.exception(
'Error running backup/restore hook for app %s: %s', self.name,
exception)
packet.errors.append(BackupError('hook', self.app, hook=hook))
def get_all_apps_for_backup():
"""Return a list of all applications that can be backed up."""
apps = []
for module_name, module in module_loader.loaded_modules.items():
try:
backup_app = BackupApp(module_name, module)
if backup_app.is_installed():
apps.append(backup_app)
module = importlib.import_module(app_.__class__.__module__)
if module.setup_helper.get_state() != 'needs-setup':
components.append(_get_backup_restore_component(app_))
except TypeError: # Application not available for backup/restore
pass
return apps
return components
def get_apps_in_order(app_names):
"""Return a list of app modules in order of dependency."""
apps = []
for module_name, module in module_loader.loaded_modules.items():
if module_name in app_names:
apps.append(BackupApp(module_name, module))
def get_components_in_order(app_ids):
"""Return a list of backup components in order of app dependencies."""
components = []
for app_ in app_module.App.list():
if app_.app_id in app_ids:
components.append(_get_backup_restore_component(app_))
return apps
return components
def _lockdown_apps(apps, lockdown):
def _lockdown_apps(components, lockdown):
"""Mark apps as in/out of lockdown mode and disable all user interaction.
This is a flag in the app module. It will enforced by a middleware that
will intercept all interaction and show a lockdown message.
"""
for app in apps:
app.app.locked = lockdown
# XXX: Lockdown the application UI by implementing a middleware
for component in components:
component.app.locked = lockdown
def _is_snapshot_available():
@ -347,6 +271,7 @@ def _switch_to_subvolume(subvolume):
class ServiceHandler:
"""Abstraction to help with service shutdown/restart."""
@staticmethod
def create(backup_app, service):
service_type = 'system'
@ -381,6 +306,7 @@ class ServiceHandler:
class SystemServiceHandler(ServiceHandler):
"""Handle starting and stopping of system services for backup."""
def __init__(self, backup_app, service):
"""Initialize the object."""
super().__init__(backup_app, service)
@ -400,6 +326,7 @@ class SystemServiceHandler(ServiceHandler):
class ApacheServiceHandler(ServiceHandler):
"""Handle starting and stopping of Apache services for backup."""
def __init__(self, backup_app, service):
"""Initialize the object."""
super().__init__(backup_app, service)
@ -424,18 +351,19 @@ class ApacheServiceHandler(ServiceHandler):
['enable', '--name', self.web_name, '--kind', self.kind])
def _shutdown_services(apps):
"""Shutdown all services specified by manifests.
def _shutdown_services(components):
"""Shutdown all services specified by backup manifests.
- Services are shutdown in the reverse order of the apps listing.
- Services are shutdown in the reverse order of the components listing.
Return the current state of the services so they can be restored
accurately.
"""
state = []
for app in apps:
for service in app.manifest.get('services', []):
state.append(ServiceHandler.create(app, service))
for component in components:
for service in component.services:
state.append(ServiceHandler.create(component, service))
for service in reversed(state):
service.stop()
@ -480,8 +408,14 @@ def _run_hooks(hook, packet):
"""
logger.info('Running %s hooks', hook)
for app in packet.apps:
app.run_hook(hook, packet)
for component in packet.components:
try:
getattr(component, hook)(packet)
except Exception as exception:
logger.exception(
'Error running backup/restore hook for app %s: %s',
component.app.app_id, exception)
packet.errors.append(BackupError('hook', component, hook=hook))
def _run_operation(handler, packet, encryption_passphrase=None):

View File

@ -0,0 +1,99 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
App component for other apps to use backup/restore functionality.
"""
from plinth import app
def _validate_directories_and_files(section):
"""Validate directories and files keys in a section."""
if not section:
return
assert isinstance(section, dict)
if 'directories' in section:
assert isinstance(section['directories'], list)
for directory in section['directories']:
assert isinstance(directory, str)
if 'files' in section:
assert isinstance(section['files'], list)
for file_path in section['files']:
assert isinstance(file_path, str)
def _validate_services(services):
"""Validate services manifest provided as list."""
if not services:
return
assert isinstance(services, list)
for service in services:
assert isinstance(service, (str, dict))
if isinstance(service, dict):
_validate_service(service)
def _validate_service(service):
"""Validate a service manifest provided as a dictionary."""
assert isinstance(service['name'], str)
assert isinstance(service['type'], str)
assert service['type'] in ('apache', 'uwsgi', 'system')
if service['type'] == 'apache':
assert service['kind'] in ('config', 'site', 'module')
class BackupRestore(app.FollowerComponent):
"""Component to backup/restore an app."""
def __init__(self, component_id, config=None, data=None, secrets=None,
services=None):
"""Initialize the backup/restore component."""
super().__init__(component_id)
_validate_directories_and_files(config)
self.config = config or {}
_validate_directories_and_files(data)
self.data = data or {}
_validate_directories_and_files(secrets)
self.secrets = secrets or {}
_validate_services(services)
self.services = services or []
self.has_data = bool(config) or bool(data) or bool(secrets)
def __eq__(self, other):
"""Check if this component is same as another."""
return self.component_id == other.component_id
@property
def manifest(self):
"""Return the backup details as a dictionary."""
manifest = {}
if self.config:
manifest['config'] = self.config
if self.secrets:
manifest['secrets'] = self.secrets
if self.data:
manifest['data'] = self.data
if self.services:
manifest['services'] = self.services
return manifest
def backup_pre(self, packet):
"""Perform any special operations before backup."""
def backup_post(self, packet):
"""Perform any special operations after backup."""
def restore_pre(self, packet):
"""Perform any special operations before restore."""
def restore_post(self, packet):
"""Perform any special operations after restore."""

View File

@ -24,16 +24,16 @@ from .repository import get_repositories
logger = logging.getLogger(__name__)
def _get_app_choices(apps):
"""Return a list of check box multiple choices from list of apps."""
def _get_app_choices(components):
"""Return a list of check box multiple choices from list of components."""
choices = []
for app in apps:
name = app.app.app.info.name
if not app.has_data:
for component in components:
name = component.app.info.name
if not component.has_data:
name = ugettext('{app} (No data to backup)').format(
app=app.app.app.info.name)
app=component.app.info.name)
choices.append((app.name, name))
choices.append((component.app_id, name))
return choices
@ -59,9 +59,12 @@ class CreateArchiveForm(forms.Form):
def __init__(self, *args, **kwargs):
"""Initialize the form with selectable apps."""
super().__init__(*args, **kwargs)
apps = api.get_all_apps_for_backup()
self.fields['selected_apps'].choices = _get_app_choices(apps)
self.fields['selected_apps'].initial = [app.name for app in apps]
components = api.get_all_components_for_backup()
choices = _get_app_choices(components)
self.fields['selected_apps'].choices = choices
self.fields['selected_apps'].initial = [
choice[0] for choice in choices
]
self.fields['repository'].choices = _get_repository_choices()
@ -72,10 +75,13 @@ class RestoreForm(forms.Form):
def __init__(self, *args, **kwargs):
"""Initialize the form with selectable apps."""
apps = kwargs.pop('apps')
components = kwargs.pop('components')
super().__init__(*args, **kwargs)
self.fields['selected_apps'].choices = _get_app_choices(apps)
self.fields['selected_apps'].initial = [app.name for app in apps]
choices = _get_app_choices(components)
self.fields['selected_apps'].choices = choices
self.fields['selected_apps'].initial = [
choice[0] for choice in choices
]
class UploadForm(forms.Form):

View File

@ -3,9 +3,7 @@
Application manifest for backups.
"""
from plinth.modules.backups.api import validate as validate_backup
# Currently, backup application does not have any settings. However, settings
# such as scheduler settings, backup location, secrets to connect to remove
# servers need to be backed up.
backup = validate_backup({})
backup = {}

View File

@ -158,6 +158,7 @@ class BaseBorgRepository(abc.ABC):
def remove(self):
"""Remove a borg repository"""
def list_archives(self):
"""Return list of archives in this repository."""
output = self.run(['list-repo', '--path', self.borg_path])
@ -165,12 +166,12 @@ class BaseBorgRepository(abc.ABC):
return sorted(archives, key=lambda archive: archive['start'],
reverse=True)
def create_archive(self, archive_name, app_names):
def create_archive(self, archive_name, app_ids):
"""Create a new archive in this repository with given name."""
archive_path = self._get_archive_path(archive_name)
passphrase = self.credentials.get('encryption_passphrase', None)
api.backup_apps(_backup_handler, path=archive_path,
app_names=app_names, encryption_passphrase=passphrase)
api.backup_apps(_backup_handler, path=archive_path, app_ids=app_ids,
encryption_passphrase=passphrase)
def delete_archive(self, archive_name):
"""Delete an archive with given name from this repository."""
@ -222,6 +223,7 @@ class BaseBorgRepository(abc.ABC):
def get_download_stream(self, archive_name):
"""Return an stream of .tar.gz binary data for a backup archive."""
class BufferedReader(io.BufferedReader):
"""Improve performance of buffered binary streaming.
@ -235,6 +237,7 @@ class BaseBorgRepository(abc.ABC):
binary data.
"""
def __next__(self):
"""Override to call read() instead of readline()."""
chunk = self.read(io.DEFAULT_BUFFER_SIZE)
@ -279,11 +282,11 @@ class BaseBorgRepository(abc.ABC):
output = self.run(['get-archive-apps', '--path', archive_path])
return output.splitlines()
def restore_archive(self, archive_name, apps=None):
def restore_archive(self, archive_name, app_ids=None):
"""Restore an archive from this repository to the system."""
archive_path = self._get_archive_path(archive_name)
passphrase = self.credentials.get('encryption_passphrase', None)
api.restore_apps(restore_archive_handler, app_names=apps,
api.restore_apps(restore_archive_handler, app_ids=app_ids,
create_subvolume=False, backup_file=archive_path,
encryption_passphrase=passphrase)

View File

@ -72,7 +72,7 @@
<tr id="archive-{{ archive.name }}" class="archive">
<td class="archive-name">{{ archive.name }}</td>
<td class="archive-operations">
<a class="archive-export btn btn-sm btn-default" target="_blank"
<a class="archive-export btn btn-sm btn-default"
href="{% url 'backups:download' uuid archive.name %}">
{% trans "Download" %}
</a>

View File

@ -8,13 +8,18 @@ from unittest.mock import MagicMock, call, patch
import pytest
from django.core.files.uploadedfile import SimpleUploadedFile
from plinth.app import App
from .. import api, forms, repository
from ..components import BackupRestore
# pylint: disable=protected-access
setup_helper = MagicMock()
def _get_test_manifest(name):
return api.validate({
return {
'config': {
'directories': ['/etc/' + name + '/config.d/'],
'files': ['/etc/' + name + '/config'],
@ -32,50 +37,42 @@ def _get_test_manifest(name):
'name': name,
'kind': 'site'
}]
})
}
def _get_backup_app(name):
"""Return a dummy BackupApp object."""
return api.BackupApp(name, MagicMock(backup=_get_test_manifest(name)))
def _get_backup_component(name):
"""Return a BackupRestore component."""
return BackupRestore(name, **_get_test_manifest(name))
class TestBackupApp:
"""Test the BackupApp class."""
@staticmethod
def test_run_hook():
"""Test running a hook on an application."""
packet = api.Packet('backup', 'apps', '/', [])
hook = 'testhook_pre'
app = MagicMock()
backup_app = api.BackupApp('app_name', app)
backup_app.run_hook(hook, packet)
class AppTest(App):
"""Sample App for testing."""
app_id = 'test-app'
app.testhook_pre.assert_has_calls([call(packet)])
assert not packet.errors
app.testhook_pre.reset_mock()
app.testhook_pre.side_effect = Exception()
backup_app.run_hook(hook, packet)
assert packet.errors == [api.BackupError('hook', app, hook=hook)]
del app.testhook_pre
backup_app.run_hook(hook, packet)
def _get_test_app(name):
"""Return an App."""
app = AppTest()
app.app_id = name
app._all_apps[name] = app
app.add(_get_backup_component(name + '-component'))
return app
@pytest.mark.usefixtures('load_cfg')
class TestBackupProcesses:
"""Test cases for backup processes"""
@staticmethod
def test_packet_process_manifests():
def test_packet_collected_files_directories():
"""Test that directories/files are collected from manifests."""
apps = [_get_backup_app('a'), _get_backup_app('b')]
packet = api.Packet('backup', 'apps', '/', apps)
for app in apps:
components = [_get_backup_component('a'), _get_backup_component('b')]
packet = api.Packet('backup', 'apps', '/', components)
for component in components:
for section in ['config', 'data', 'secrets']:
for directory in app.manifest[section]['directories']:
for directory in getattr(component, section)['directories']:
assert directory in packet.directories
for file_path in app.manifest[section]['files']:
for file_path in getattr(component, section)['files']:
assert file_path in packet.files
@staticmethod
@ -97,50 +94,56 @@ class TestBackupProcesses:
restore_handler.assert_called_once()
@staticmethod
@patch('plinth.module_loader.loaded_modules.items')
def test_get_all_apps_for_backup(modules):
"""Test listing apps supporting backup and needing backup."""
apps = [
('a', MagicMock(backup=_get_test_manifest('a'))),
('b', MagicMock(backup=_get_test_manifest('b'))),
('c', MagicMock(backup=None)),
('d', MagicMock()),
]
del apps[3][1].backup
modules.return_value = apps
@patch('importlib.import_module')
@patch('plinth.app.App.list')
def test_get_all_components_for_backup(apps_list, import_module):
"""Test listing components supporting backup and needing backup."""
modules = [MagicMock(), MagicMock(), MagicMock()]
import_module.side_effect = modules
apps = [_get_test_app('a'), _get_test_app('b'), _get_test_app('c')]
modules[1].setup_helper.get_state.side_effect = ['needs-setup']
apps_list.return_value = apps
returned_apps = api.get_all_apps_for_backup()
expected_apps = [
api.BackupApp('a', apps[0][1]),
api.BackupApp('b', apps[1][1]),
api.BackupApp('c', apps[2][1])
returned_components = api.get_all_components_for_backup()
expected_components = [
apps[0].components['a-component'],
apps[2].components['c-component']
]
assert returned_apps == expected_apps
assert returned_components == expected_components
@staticmethod
@patch('plinth.module_loader.loaded_modules.items')
def test_get_apps_in_order(modules):
"""Test that apps are listed in correct dependency order."""
@patch('plinth.app.App.list')
def test_get_components_in_order(apps_list):
"""Test that components are listed in correct dependency order."""
apps = [
('names', MagicMock(backup=_get_test_manifest('names'))),
('config', MagicMock(backup=_get_test_manifest('config'))),
_get_test_app('names'),
_get_test_app('other'),
_get_test_app('config')
]
modules.return_value = apps
apps_list.return_value = apps
app_names = ['config', 'names']
apps = api.get_apps_in_order(app_names)
assert apps[0].name == 'names'
assert apps[1].name == 'config'
app_ids = ['config', 'names']
components = api.get_components_in_order(app_ids)
assert len(components) == 2
assert components[0].app_id == 'names'
assert components[1].app_id == 'config'
@staticmethod
def test__lockdown_apps():
"""Test that locked flag is set for each app."""
app_a = MagicMock(locked=False)
app_b = MagicMock(locked=None)
apps = [MagicMock(app=app_a), MagicMock(app=app_b)]
api._lockdown_apps(apps, True)
assert app_a.locked is True
assert app_b.locked is True
apps = [_get_test_app('test-app-1'), _get_test_app('test-app-2')]
components = [
apps[0].components['test-app-1-component'],
apps[1].components['test-app-2-component']
]
api._lockdown_apps(components, True)
assert apps[0].locked
assert apps[1].locked
api._lockdown_apps(components, False)
assert not apps[0].locked
assert not apps[1].locked
@staticmethod
@patch('plinth.action_utils.webserver_is_enabled')
@ -148,19 +151,20 @@ class TestBackupProcesses:
@patch('plinth.actions.superuser_run')
def test__shutdown_services(run, service_is_running, webserver_is_enabled):
"""Test that services are stopped in correct order."""
apps = [_get_backup_app('a'), _get_backup_app('b')]
components = [_get_backup_component('a'), _get_backup_component('b')]
service_is_running.return_value = True
webserver_is_enabled.return_value = True
state = api._shutdown_services(apps)
state = api._shutdown_services(components)
expected_state = [
api.ServiceHandler.create(apps[0],
apps[0].manifest['services'][0]),
api.ServiceHandler.create(apps[0],
apps[0].manifest['services'][1]),
api.ServiceHandler.create(apps[1],
apps[1].manifest['services'][0]),
api.ServiceHandler.create(apps[1], apps[1].manifest['services'][1])
api.ServiceHandler.create(components[0],
components[0].services[0]),
api.ServiceHandler.create(components[0],
components[0].services[1]),
api.ServiceHandler.create(components[1],
components[1].services[0]),
api.ServiceHandler.create(components[1],
components[1].services[1]),
]
assert state == expected_state
@ -207,21 +211,26 @@ class TestBackupProcesses:
@staticmethod
def test__run_operation():
"""Test that operation runs handler and app hooks."""
apps = [_get_backup_app('a'), _get_backup_app('b')]
packet = api.Packet('backup', 'apps', '/', apps)
packet.apps[0].run_hook = MagicMock()
packet.apps[1].run_hook = MagicMock()
components = [_get_backup_component('a'), _get_backup_component('b')]
packet = api.Packet('backup', 'apps', '/', components)
packet.components[0].backup_pre = MagicMock()
packet.components[0].backup_post = MagicMock()
packet.components[1].backup_pre = MagicMock()
packet.components[1].backup_post = MagicMock()
handler = MagicMock()
api._run_operation(handler, packet)
handler.assert_has_calls([call(packet, encryption_passphrase=None)])
calls = [call('backup_pre', packet), call('backup_post', packet)]
packet.apps[0].run_hook.assert_has_calls(calls)
packet.apps[1].run_hook.assert_has_calls(calls)
calls = [call(packet)]
packet.components[0].backup_pre.assert_has_calls(calls)
packet.components[0].backup_post.assert_has_calls(calls)
packet.components[1].backup_pre.assert_has_calls(calls)
packet.components[1].backup_post.assert_has_calls(calls)
class TestBackupModule:
"""Tests of the backups django module, like views or forms."""
@staticmethod
def test_file_upload():
# posting a video should fail

View File

@ -0,0 +1,212 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
"""
Test the App components provides by backups app.
"""
import pytest
from .. import components
from ..components import BackupRestore
# pylint: disable=protected-access
@pytest.fixture(name='backup_restore')
def fixture_backup_restore():
"""Fixture to create a domain type after clearing all existing ones."""
value = {'files': ['a', 'b'], 'directories': ['a', 'b']}
services = ['service-1', {'type': 'system', 'name': 'service-2'}]
return BackupRestore('test-backup-restore', config=value, data=value,
secrets=value, services=services)
@pytest.mark.parametrize('section', [
None,
{
'directories': ['a', 'b']
},
{
'files': ['a', 'b']
},
{
'directories': ['a'],
'files': ['a']
},
{
'extra': 'value'
},
])
def test_valid_directories_and_files(section):
"""Test that valid values of files and directories."""
components._validate_directories_and_files(section)
@pytest.mark.parametrize('section', [
'invalid',
10,
['invalid'],
{
'files': None
},
{
'files': 10
},
{
'files': {}
},
{
'files': [10],
},
{
'files': [None],
},
{
'files': [[]],
},
{
'directories': None
},
{
'directories': [10],
},
])
def test_invalid_directories_and_files(section):
"""Test that invalid values of files and directories."""
with pytest.raises(AssertionError):
components._validate_directories_and_files(section)
@pytest.mark.parametrize('services', [
None,
[],
['service'],
[{
'type': 'uwsgi',
'name': 'service'
}],
[{
'type': 'system',
'name': 'service'
}],
[{
'type': 'apache',
'name': 'service',
'kind': 'config'
}],
[{
'type': 'apache',
'name': 'service',
'kind': 'site'
}],
[{
'type': 'apache',
'name': 'service',
'kind': 'module'
}],
])
def test_valid_services(services):
"""Test that valid values of services."""
components._validate_services(services)
@pytest.mark.parametrize('services', [
10,
'invalid',
[10],
[[]],
[{}],
[{
'type': 'invalid',
'name': 'service'
}],
[{
'type': 10,
'name': 'service'
}],
[{
'type': 'system',
'name': 10
}],
[{
'type': 'system',
'name': None
}],
[{
'type': 'system',
'name': []
}],
[{
'type': 'apache',
'name': 'service'
}],
[{
'type': 'apache',
'name': 'service',
'kind': 'invalid-kind'
}],
])
def test_invalid_services(services):
"""Test that invalid values of services."""
with pytest.raises((AssertionError, KeyError)):
components._validate_services(services)
def test_backup_restore_init_default_arguments():
"""Test initialization of the backup restore object."""
component = BackupRestore('test-backup-restore')
assert component.component_id == 'test-backup-restore'
assert component.config == {}
assert component.data == {}
assert component.secrets == {}
assert component.services == []
assert not component.has_data
@pytest.mark.parametrize('key', ['config', 'data', 'secrets'])
def test_backup_restore_init(key):
"""Test initialization of the backup restore object."""
with pytest.raises(AssertionError):
BackupRestore('test-backup-restore', **{key: 'invalid-value'})
value = {'files': ['a', 'b'], 'directories': ['a', 'b']}
component = BackupRestore('test-backup-restore', **{key: value})
assert getattr(component, key) == value
assert component.has_data
def test_backup_restore_init_services():
"""Test initialization of the backup restore object."""
with pytest.raises(AssertionError):
BackupRestore('test-backup-restore', services='invalid-value')
services = ['service-1', {'type': 'system', 'name': 'service-2'}]
component = BackupRestore('test-backup-restore', services=services)
assert component.services == services
assert not component.has_data
def test_backup_restore_equal(backup_restore):
"""Test equality operator on the backup restore object."""
assert backup_restore == BackupRestore('test-backup-restore')
assert backup_restore != BackupRestore('test-different')
def test_backup_restore_manifest(backup_restore):
"""Test manifest retrieval from backup restore object."""
manifest = backup_restore.manifest
assert isinstance(manifest, dict)
assert manifest['config'] == backup_restore.config
assert manifest['data'] == backup_restore.data
assert manifest['secrets'] == backup_restore.secrets
assert manifest['services'] == backup_restore.services
assert BackupRestore('test-backup-restore').manifest == {}
def test_backup_restore_hooks(backup_restore):
"""Test running hooks on backup restore object."""
packet = None
backup_restore.backup_pre(packet)
backup_restore.backup_post(packet)
backup_restore.restore_pre(packet)
backup_restore.restore_post(packet)

View File

@ -147,7 +147,7 @@ class BaseRestoreView(SuccessMessageMixin, FormView):
"""Pass additional keyword args for instantiating the form."""
kwargs = super().get_form_kwargs()
included_apps = self._get_included_apps()
kwargs['apps'] = api.get_apps_in_order(included_apps)
kwargs['components'] = api.get_components_in_order(included_apps)
return kwargs
def get_context_data(self, **kwargs):

View File

@ -11,9 +11,10 @@ from plinth import actions
from plinth import app as app_module
from plinth import frontpage, menu
from plinth.modules.apache.components import Uwsgi, Webserver
from plinth.modules.backups.components import BackupRestore
from plinth.modules.firewall.components import Firewall
from .manifest import backup, clients # noqa, pylint: disable=unused-import
from . import manifest
version = 2
@ -64,7 +65,7 @@ class BepastyApp(app_module.App):
icon_filename='bepasty',
short_description=_('File & Snippet Sharing'),
description=_description, manual_page='bepasty',
clients=clients)
clients=manifest.clients)
self.add(info)
menu_item = menu.Menu('menu-bepasty', info.name,
@ -75,7 +76,7 @@ class BepastyApp(app_module.App):
shortcut = frontpage.Shortcut('shortcut-bepasty', info.name,
info.short_description,
info.icon_filename, '/bepasty',
clients=clients)
clients=manifest.clients)
self.add(shortcut)
firewall = Firewall('firewall-bepasty', info.name,
@ -89,6 +90,10 @@ class BepastyApp(app_module.App):
urls=['https://{host}/bepasty/'])
self.add(webserver)
backup_restore = BackupRestore('backup-restore-bepasty',
**manifest.backup)
self.add(backup_restore)
def setup(helper, old_version=None):
"""Install and configure the module."""

View File

@ -2,18 +2,15 @@
from django.utils.translation import ugettext_lazy as _
from plinth.clients import validate
from plinth.modules.backups.api import validate as validate_backup
clients = validate([{
clients = [{
'name': _('bepasty'),
'platforms': [{
'type': 'web',
'url': '/bepasty'
}]
}])
}]
backup = validate_backup({
backup = {
'config': {
'files': ['/etc/bepasty-freedombox.conf']
},
@ -21,4 +18,4 @@ backup = validate_backup({
'directories': ['/var/lib/bepasty']
},
'services': ['uwsgi'],
})
}

View File

@ -14,10 +14,11 @@ from plinth import actions
from plinth import app as app_module
from plinth import cfg, menu
from plinth.daemon import Daemon
from plinth.modules.backups.components import BackupRestore
from plinth.modules.firewall.components import Firewall
from plinth.utils import format_lazy
from .manifest import backup # noqa, pylint: disable=unused-import
from . import manifest
version = 2
@ -96,6 +97,10 @@ class BindApp(app_module.App):
alias=managed_services[1])
self.add(daemon)
backup_restore = BackupRestore('backup-restore-bind',
**manifest.backup)
self.add(backup_restore)
def setup(helper, old_version=None):
"""Install and configure the module."""

View File

@ -3,11 +3,9 @@
Application manifest for bind.
"""
from plinth.modules.backups.api import validate as validate_backup
backup = validate_backup({
backup = {
'config': {
'files': ['/etc/bind/named.conf.options']
},
'services': ['bind9']
})
}

View File

@ -13,11 +13,12 @@ from plinth import app as app_module
from plinth import cfg, frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.backups.components import BackupRestore
from plinth.modules.firewall.components import Firewall
from plinth.modules.users.components import UsersAndGroups
from plinth.utils import format_lazy
from .manifest import backup, clients # noqa, pylint: disable=unused-import
from . import manifest
version = 1
@ -60,7 +61,7 @@ class CalibreApp(app_module.App):
name=_('calibre'), icon_filename='calibre',
short_description=_('E-book Library'),
description=_description, manual_page='Calibre',
clients=clients,
clients=manifest.clients,
donation_url='https://calibre-ebook.com/donate')
self.add(info)
@ -94,6 +95,10 @@ class CalibreApp(app_module.App):
groups=groups)
self.add(users_and_groups)
backup_restore = BackupRestore('backup-restore-calibre',
**manifest.backup)
self.add(backup_restore)
def setup(helper, old_version=None):
"""Install and configure the module."""

View File

@ -2,20 +2,17 @@
from django.utils.translation import ugettext_lazy as _
from plinth.clients import validate
from plinth.modules.backups.api import validate as validate_backup
clients = validate([{
clients = [{
'name': _('calibre'),
'platforms': [{
'type': 'web',
'url': '/calibre/'
}]
}])
}]
backup = validate_backup({
backup = {
'data': {
'directories': ['/var/lib/private/calibre-server-freedombox/']
},
'services': ['calibre-server-freedombox']
})
}

View File

@ -12,12 +12,12 @@ from plinth import cfg, frontpage, menu
from plinth.daemon import Daemon
from plinth.modules import names
from plinth.modules.apache.components import Webserver
from plinth.modules.backups.components import BackupRestore
from plinth.modules.firewall.components import Firewall
from plinth.signals import domain_added, domain_removed
from plinth.utils import format_lazy
from . import utils
from .manifest import backup, clients # noqa, pylint: disable=unused-import
from . import manifest, utils
version = 1
@ -65,7 +65,7 @@ class CockpitApp(app_module.App):
icon='fa-wrench', icon_filename='cockpit',
short_description=_('Server Administration'),
description=_description, manual_page='Cockpit',
clients=clients)
clients=manifest.clients)
self.add(info)
menu_item = menu.Menu('menu-cockpit', info.name,
@ -92,6 +92,10 @@ class CockpitApp(app_module.App):
daemon = Daemon('daemon-cockpit', managed_services[0])
self.add(daemon)
backup_restore = BackupRestore('backup-restore-cockpit',
**manifest.backup)
self.add(backup_restore)
domain_added.connect(on_domain_added)
domain_removed.connect(on_domain_removed)

View File

@ -5,19 +5,16 @@ Application manifest for cockpit.
from django.utils.translation import ugettext_lazy as _
from plinth.modules.backups.api import validate as validate_backup
from plinth.clients import validate
clients = validate([{
clients = [{
'name': _('Cockpit'),
'platforms': [{
'type': 'web',
'url': '/_cockpit/'
}]
}])
}]
# cockpit.conf need not be backed up because add/remove domain signals are
# triggered on every Plinth domain change (and cockpit application install) and
# will set the value of allowed domains correctly. This is the only key the is
# customized in cockpit.conf.
backup = validate_backup({})
backup = {}

View File

@ -13,11 +13,12 @@ from plinth import app as app_module
from plinth import menu
from plinth.daemon import Daemon
from plinth.modules import names
from plinth.modules.backups.components import BackupRestore
from plinth.modules.firewall.components import Firewall
from plinth.modules.letsencrypt.components import LetsEncrypt
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup # noqa, pylint: disable=unused-import
from . import manifest
version = 1
@ -86,6 +87,10 @@ class CoturnApp(app_module.App):
reserved_usernames=['turnserver'])
self.add(users_and_groups)
backup_restore = BackupRestore('backup-restore-coturn',
**manifest.backup)
self.add(backup_restore)
def setup(helper, old_version=None):
"""Install and configure the module."""

View File

@ -1,10 +1,3 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
from plinth.modules.backups.api import validate as validate_backup
backup = validate_backup({
'secrets': {
'directories': ['/etc/coturn']
},
'services': ['coturn']
})
backup = {'secrets': {'directories': ['/etc/coturn']}, 'services': ['coturn']}

View File

@ -10,8 +10,9 @@ from django.utils.translation import ugettext_lazy as _
from plinth import app as app_module
from plinth import menu
from plinth.daemon import Daemon
from plinth.modules.backups.components import BackupRestore
from .manifest import backup # noqa, pylint: disable=unused-import
from . import manifest
version = 2
@ -79,6 +80,10 @@ class DateTimeApp(app_module.App):
daemon = Daemon('daemon-datetime', managed_services[0])
self.add(daemon)
backup_restore = BackupRestore('backup-restore-datetime',
**manifest.backup)
self.add(backup_restore)
def diagnose(self):
"""Run diagnostics and return the results."""
results = super().diagnose()

View File

@ -3,6 +3,4 @@
Application manifest for datetime.
"""
from plinth.modules.backups.api import validate as validate_backup
backup = validate_backup({'data': {'files': ['/etc/timezone']}})
backup = {'data': {'files': ['/etc/timezone']}}

View File

@ -10,11 +10,12 @@ from plinth import app as app_module
from plinth import frontpage, menu
from plinth.daemon import Daemon
from plinth.modules.apache.components import Webserver
from plinth.modules.backups.components import BackupRestore
from plinth.modules.firewall.components import Firewall
from plinth.modules.users import add_user_to_share_group
from plinth.modules.users.components import UsersAndGroups
from .manifest import backup, clients # noqa, pylint: disable=unused-import
from . import manifest
version = 6
@ -50,7 +51,8 @@ class DelugeApp(app_module.App):
app_id=self.app_id, version=version, name=_('Deluge'),
icon_filename='deluge',
short_description=_('BitTorrent Web Client'),
description=_description, manual_page='Deluge', clients=clients,
description=_description, manual_page='Deluge',
clients=manifest.clients,
donation_url='https://www.patreon.com/deluge_cas')
self.add(info)
@ -88,6 +90,10 @@ class DelugeApp(app_module.App):
groups=groups)
self.add(users_and_groups)
backup_restore = BackupRestore('backup-restore-deluge',
**manifest.backup)
self.add(backup_restore)
def setup(helper, old_version=None):
"""Install and configure the module."""

View File

@ -2,21 +2,18 @@
from django.utils.translation import ugettext_lazy as _
from plinth.clients import validate
from plinth.modules.backups.api import validate as validate_backup
clients = validate([{
clients = [{
'name': _('Deluge'),
'description': _('Bittorrent client written in Python/PyGTK'),
'platforms': [{
'type': 'web',
'url': '/deluge'
}]
}])
}]
backup = validate_backup({
backup = {
'config': {
'directories': ['/var/lib/deluged/.config']
},
'services': ['deluged', 'deluge-web']
})
}

View File

@ -15,14 +15,14 @@ Scenario: Enable deluge application
Scenario: User of 'bit-torrent' group
Given the deluge application is enabled
When I create a user named delugeuser with password deluge&456 in group bit-torrent
And I'm logged in as the user delugeuser with password deluge&456
And the user delugeuser in group bit-torrent exists
When I'm logged in as the user delugeuser
Then the deluge site should be available
Scenario: User not of 'bit-torrent' group
Given the deluge application is enabled
When I create a user named nogroupuser with password somep@ssw6
And I'm logged in as the user nogroupuser with password somep@ssw6
And the user nogroupuser exists
When I'm logged in as the user nogroupuser
Then the deluge site should not be available
Scenario: Upload a torrent to deluge

View File

@ -16,8 +16,9 @@ from django.utils.translation import ugettext_noop
from plinth import app as app_module
from plinth import cfg, daemon, glib, menu
from plinth.modules.apache.components import diagnose_url_on_all
from plinth.modules.backups.components import BackupRestore
from .manifest import backup # noqa, pylint: disable=unused-import
from . import manifest
version = 1
@ -57,6 +58,10 @@ class DiagnosticsApp(app_module.App):
'diagnostics:index', parent_url_name='system')
self.add(menu_item)
backup_restore = BackupRestore('backup-restore-diagnostics',
**manifest.backup)
self.add(backup_restore)
# Check periodically for low RAM space
interval = 180 if cfg.develop else 3600
glib.schedule(interval, _warn_about_low_ram_space)

View File

@ -3,6 +3,4 @@
Application manifest for diagnostics.
"""
from plinth.modules.backups.api import validate as validate_backup
backup = validate_backup({})
backup = {}

View File

@ -52,8 +52,6 @@ _description = [
' federate with other diaspora* pods.')
]
from .manifest import clients # noqa pylint:disable=E402 isort:skip
app = None
@ -65,10 +63,12 @@ class DiasporaApp(app_module.App):
def __init__(self):
"""Create components for the app."""
super().__init__()
from . import manifest
info = app_module.Info(app_id=self.app_id, version=version,
name=_('diaspora*'), icon_filename='diaspora',
short_description=_('Federated Social Network'),
description=_description, clients=clients)
description=_description,
clients=manifest.clients)
self.add(info)
menu_item = menu.Menu('menu-diaspora', info.name,

Some files were not shown because too many files have changed in this diff Show More