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

Installation of fpyutils#

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