From 0475e30a88c8bdd2b130d11630fcf4ef952e39fe Mon Sep 17 00:00:00 2001 From: Simon Holesch Date: Mon, 29 Jan 2024 04:13:00 +0100 Subject: [PATCH] Add initial version of sdnotify-wrapper --- .github/workflows/on-push.yml | 48 +++++++++++++ LICENSE.txt | 21 ++++++ README.md | 1 + get_version | 39 +++++++++++ meson.build | 10 +++ pyproject.toml | 60 ++++++++++++++++ sdnotify_wrapper.py | 127 ++++++++++++++++++++++++++++++++++ 7 files changed, 306 insertions(+) create mode 100644 .github/workflows/on-push.yml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100755 get_version create mode 100644 meson.build create mode 100644 pyproject.toml create mode 100755 sdnotify_wrapper.py diff --git a/.github/workflows/on-push.yml b/.github/workflows/on-push.yml new file mode 100644 index 0000000..67a31f8 --- /dev/null +++ b/.github/workflows/on-push.yml @@ -0,0 +1,48 @@ +name: on-push + +on: [push] + +jobs: + static_checks: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: 3.11 + - name: Install dependencies + run: | + pip install .[test] + - name: Lint with Pylint + run: pylint --score=n *.py + - name: Check format with Black + run: black --check . + - name: Check import statement order with isort + run: isort --check . + - name: Check spelling + run: git ls-files -z | xargs -0 -- codespell + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v3 + with: + python-version: 3.11 + - name: Install dependencies + run: | + # python3-build is broken on 22.04: + # https://bugs.launchpad.net/ubuntu/+source/python-build/+bug/1992108 + # install everything from pip instead + pip install build twine + - name: Build dist packages + run: | + export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) + python3 -m build + - name: Upload to PyPi + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + if: ${{ github.ref_type == 'tag' && startsWith(github.ref_name, 'v') && env.TWINE_PASSWORD != '' }} + run: twine upload --non-interactive dist/* diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..2c576ea --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-present Simon Holesch + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b84b5a6 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# sdnotify-wrapper-py diff --git a/get_version b/get_version new file mode 100755 index 0000000..e0579ea --- /dev/null +++ b/get_version @@ -0,0 +1,39 @@ +#!/bin/sh -e +# called by meson to get project version + +main() { + if [ "$1" = "--save" ]; then + get_git_version > "${MESON_DIST_ROOT:?}/VERSION" + elif [ -e VERSION ]; then + cat VERSION + else + get_git_version + fi +} + +get_git_version() { + version="$(git_describe)" + version="${version#v}" # remove "v" prefix + + case "$version" in + *-*) + # not an exact match: make version PEP 440 compliant + # e.g. 1.2.3-5-gd63c80c is turned into 1.2.3.dev5+gd63c80c + latest_release="${version%%-*}" + suffix="${version#*-}" + dev_num="${suffix%%-*}" + commit="${suffix#*-}" + echo "$latest_release.dev$dev_num+$commit" + ;; + *) + echo "$version" + esac +} + +git_describe() { + if ! git -C "${MESON_SOURCE_ROOT:-$PWD}" describe --match "v*" --tags 2>/dev/null; then + echo "v0.1.0-0-g$(git rev-parse --short HEAD)" + fi +} + +main "$@" diff --git a/meson.build b/meson.build new file mode 100644 index 0000000..91e39a5 --- /dev/null +++ b/meson.build @@ -0,0 +1,10 @@ +project( + 'sdnotify-wrapper-py', + version: run_command('./get_version', check: true).stdout().strip() +) + +meson.add_dist_script('./get_version', '--save') + +py = import('python').find_installation() + +py.install_sources('sdnotify_wrapper.py') diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..650541b --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,60 @@ +[project] +# For more information on how to specify metadata, see +# https://packaging.python.org/en/latest/specifications/pyproject-toml/ +name = "sdnotify-wrapper-py" +description = "Notify readiness to systemd by writing to stdout in a service" +readme = "README.md" +requires-python = ">=3.7" +license = {file = "LICENSE.txt"} +authors = [ + {name = "Simon Holesch", email = "simon@holesch.de"}, +] +keywords = ["systemd", "readiness", "sd_notify"] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: System :: Boot :: Init", + "Topic :: System :: Systems Administration", +] +dependencies = [] +dynamic = ["version"] + +[project.urls] +Issues = "https://github.com/holesch/sdnotify-wrapper-py/issues" +Source = "https://github.com/holesch/sdnotify-wrapper-py" + +[project.scripts] +sdnotify-wrapper = "sdnotify_wrapper:main" + +[project.optional-dependencies] +test = [ + "black", + "codespell", + "isort", + "pylint", +] + +[build-system] +build-backend = "mesonpy" +requires = ["meson-python"] + +[tool.pylint."MESSAGES CONTROL"] +disable = [ + "missing-class-docstring", + "missing-function-docstring", + "missing-module-docstring", +] + +[tool.isort] +profile = "black" diff --git a/sdnotify_wrapper.py b/sdnotify_wrapper.py new file mode 100755 index 0000000..44453ed --- /dev/null +++ b/sdnotify_wrapper.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 + +import getopt +import os +import struct +import sys +import typing + + +def usage(): + print( + "usage: sdnotify-wrapper [ -d fd ] [ -f ] [ -t timeout ] [ -k ] prog...", + file=sys.stderr, + ) + + +def main(): + args = Arguments.parse() + + if "NOTIFY_SOCKET" not in os.environ: + os.execvp(args.argv[0], args.argv) + + main_pid = os.getpid() + read_pipe, write_pipe = os.pipe() + + pid = os.fork() if args.fork_once else double_fork() + if is_forked_child(pid): + os.close(write_pipe) + return run_child(read_pipe, args.timeout, main_pid) + + os.close(read_pipe) + os.dup2(write_pipe, args.fd) + os.close(write_pipe) + + if not args.keep: + del os.environ["NOTIFY_SOCKET"] + + return os.execvp(args.argv[0], args.argv) + + +class Arguments(typing.NamedTuple): + argv: typing.List[str] + fd: int = 1 # default is stdout + fork_once: bool = False + timeout: typing.Optional[int] = None + keep: bool = False + + @classmethod + def parse(cls): + try: + opts, args = getopt.getopt(sys.argv[1:], "d:ft:k") + except getopt.GetoptError as err: + print(err, file=sys.stderr) + usage() + sys.exit(2) + + init_args = {} + for opt, arg in opts: + if opt == "-d": + init_args["fd"] = arg + elif opt == "-f": + init_args["fork_once"] = True + elif opt == "-t": + init_args["timeout"] = arg + elif opt == "-k": + init_args["keep"] = True + else: + assert False, "unhandled option" + + if not args: + usage() + sys.exit(2) + + return cls(argv=args, **init_args) + + +# pylint: disable=R1732 +# - consider-using-with: can't use a context manager when using fork() +def double_fork(): + read_pipe, write_pipe = os.pipe() + read_pipe = open(read_pipe, "rb") + write_pipe = open(write_pipe, "wb") + + pid = os.fork() + if is_forked_child(pid): + read_pipe.close() + pid = os.fork() + if is_forked_child(pid): + # grandchild + write_pipe.close() + return 0 + + msg = struct.pack("!Q", pid) + write_pipe.write(msg) + write_pipe.flush() + os._exit(0) + + write_pipe.close() + msg = read_pipe.read(8) + read_pipe.close() + os.waitpid(pid, 0) # wait for child #1 to exit + grandchild = struct.unpack("!Q", msg)[0] + return grandchild + + +def is_forked_child(pid): + return pid == 0 + + +# pylint: disable=W0613 +# - unused-argument: timeout is not implemented, yet +def run_child(read_pipe, timeout, main_pid): + data = b"" + while b"\n" not in data: + data = os.read(read_pipe, 4096) + if not data: + return 1 + return notify_systemd(main_pid) + + +def notify_systemd(main_pid): + argv = ["systemd-notify", "--ready", f"--pid={main_pid}"] + os.execvp(argv[0], argv) + + +if __name__ == "__main__": + sys.exit(main())