Prerequisites#

Explanation#

Script and services are placed in a home directory called jobs

  • scripts: /home/jobs/scripts/by-user/${running_username}

  • Systemd service files: /home/jobs/services/by-user/${running_username}

Instructions#

All instructions must be run be the root user by default. You should access the root user with

sudo -i

Specific instructions are available

  • when new users must be created

  • when commands need to be run be users other than root

  1. create the jobs user and the directories

    useradd -m -s /bin/bash -U jobs
    mkdir -p /home/jobs/{script,services}/by-user
    chown -R jobs:jobs /home/jobs
    chmod -R 070 /home/jobs
    
  2. add the running username to the jobs group

    usermod -aG jobs ${running_username}
    
  3. when you change a service file run the deploy script as root. This deploy script will copy the Systemd files in the appropriate directories and run and enable services and timers. You need to install:

    • Python 3

    • fpyutils (see also the next section)

    to be able to run this script

    /home/jobs/services/deploy.py#
      1#!/usr/bin/env python3
      2#
      3# deploy.py
      4#
      5# Copyright (C) 2019-2022 Franco Masotti (franco \D\o\T masotti {-A-T-} tutanota \D\o\T com)
      6#
      7# This program is free software: you can redistribute it and/or modify
      8# it under the terms of the GNU General Public License as published by
      9# the Free Software Foundation, either version 3 of the License, or
     10# (at your option) any later version.
     11#
     12# This program is distributed in the hope that it will be useful,
     13# but WITHOUT ANY WARRANTY; without even the implied warranty of
     14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     15# GNU General Public License for more details.
     16#
     17# You should have received a copy of the GNU General Public License
     18# along with this program.  If not, see <http://www.gnu.org/licenses/>.
     19r"""Deploy the unit files."""
     20
     21import multiprocessing
     22import os
     23import pathlib
     24import shlex
     25import shutil
     26import subprocess
     27
     28import fpyutils
     29
     30SRC_DIR = '/home/jobs/services/by-user'
     31DST_DIR = '/etc/systemd/system'
     32
     33
     34class UserNotRoot(Exception):
     35    r"""The user running the script is not root."""
     36
     37
     38def get_unit_files(start_dir: str = '.', max_depth: int = 0) -> tuple:
     39    r"""Get the file names of the unit files."""
     40    unit_file: list = list()
     41    p = pathlib.Path(start_dir)
     42
     43    # A file has a fake depth of 1 even if we are at level 0 of the directory tree.
     44    # The reason of this is how the depth is computed below.
     45    max_depth += 1
     46
     47    # Match files and directories.
     48    for f in p.rglob('*'):
     49        if f.is_file() and len(
     50                pathlib.PurePath(f.relative_to(
     51                    pathlib.PurePath(start_dir))).parts) <= max_depth:
     52            if pathlib.PurePath(f).match('*.timer') or pathlib.PurePath(
     53                    f).match('*.service'):
     54                unit_file.append(f)
     55
     56    return unit_file
     57
     58
     59def copy_unit_files(unit_files: list, dst_dir: str = DST_DIR):
     60    r"""Copy multiple unit files."""
     61    for f in unit_files:
     62        shutil.copyfile(
     63            str(f), str(pathlib.Path(shlex.quote(dst_dir),
     64                                     shlex.quote(f.name))))
     65
     66
     67def start_and_enable_unit(unit_name: str):
     68    r"""Start and enable units."""
     69    o1 = subprocess.run(shlex.split(
     70        'systemctl enable --full --quiet --no-reload --show-transaction --no-block '
     71        + shlex.quote(unit_name)),
     72                        check=True,
     73                        capture_output=True).stderr.decode('UTF-8').strip()
     74    if o1 != '':
     75        print(o1)
     76
     77    # If `systemctl is-enabled` returns "disabled" its return value is 1.
     78    # For this reason check is set to false.
     79    status = subprocess.run(
     80        shlex.split('systemctl is-enabled ' + shlex.quote(unit_name)),
     81        check=False,
     82        capture_output=True).stdout.decode('UTF-8').strip()
     83    disable = True
     84    if status in ['enabled', 'enabled-runtime']:
     85        disable = False
     86    elif status in ['static']:
     87        # Completely disable units without the '[Install]' section.
     88        disable = True
     89
     90    if disable:
     91        try:
     92            o2 = subprocess.run(shlex.split(
     93                'systemctl disable --full --quiet --no-reload --show-transaction --no-block --now '
     94                + shlex.quote(unit_name)),
     95                                check=True,
     96                                capture_output=True).stderr.decode(
     97                                    'UTF-8').strip()
     98            if o2 != '':
     99                print(o2)
    100        except subprocess.CalledProcessError:
    101            # A new template unit, the ones with 'name@.service' as filename,
    102            # without the '[Install]' section cannot be stopped nor disabled.
    103            # See https://wiki.archlinux.org/index.php/Systemd#Using_units
    104            pass
    105    else:
    106        o2 = subprocess.run(shlex.split(
    107            'systemctl start --full --quiet --no-reload --show-transaction --no-block '
    108            + shlex.quote(unit_name)),
    109                            check=True,
    110                            capture_output=True).stderr.decode(
    111                                'UTF-8').strip()
    112        if o2 != '':
    113            print(o2)
    114
    115
    116def start_and_enable_units(unit_files: list) -> int:
    117    r"""Start and enable all services and timers."""
    118    # Not all services have timer files but all timers have service files.
    119    # For these cases start and enable the service instead of the timer file.
    120    concurrent_workers: int = multiprocessing.cpu_count()
    121    final_list: list = list()
    122    timer_tmp: list = list()
    123    service_tmp: list = list()
    124
    125    for unit in unit_files:
    126        if unit.name.endswith('.timer'):
    127            timer_tmp.append(pathlib.Path(unit.name).stem)
    128        elif unit.name.endswith('.service'):
    129            service_tmp.append(pathlib.Path(unit.name).stem)
    130
    131    # O(n**2).
    132    for s in service_tmp:
    133        if s in timer_tmp:
    134            final_list.append(s + '.timer')
    135        else:
    136            final_list.append(s + '.service')
    137
    138    args = iter(final_list)
    139    worker = start_and_enable_unit
    140
    141    with multiprocessing.get_context('fork').Pool(
    142            processes=concurrent_workers) as pool:
    143        try:
    144            pool.imap_unordered(worker, args, 20)
    145        except (KeyboardInterrupt, InterruptedError):
    146            pool.terminate()
    147            pool.join()
    148        else:
    149            pool.close()
    150            pool.join()
    151
    152    return len(final_list)
    153
    154
    155if __name__ == '__main__':
    156    if os.getuid() != 0:
    157        raise UserNotRoot
    158
    159    unit_files: int = get_unit_files(SRC_DIR, 1)
    160    copy_unit_files(unit_files, DST_DIR)
    161
    162    fpyutils.shell.execute_command_live_output('systemctl daemon-reload')
    163    fpyutils.shell.execute_command_live_output('systemctl reset-failed')
    164
    165    total_unit_files: int = start_and_enable_units(unit_files)
    166
    167    fpyutils.shell.execute_command_live_output('systemctl daemon-reload')
    168    fpyutils.shell.execute_command_live_output('systemctl reset-failed')
    169
    170    print('\na total of ' + str(total_unit_files) +
    171          ' units have been handled by deploy.py')
    172    print('check units by running "systemctl"')
    

Installation of fpyutils#

The recommended way, for the moment, is to install fpyutils globally (avaiable for all users)