diff --git a/.gitignore b/.gitignore index d4b947b..67f29f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ *~ *.pyc .coverage -dist/ docs/_build/ \ No newline at end of file diff --git a/.pylintrc b/.pylintrc deleted file mode 100644 index 7eb5e2c..0000000 --- a/.pylintrc +++ /dev/null @@ -1,4 +0,0 @@ -# -*- mode: conf; -*- - -[MESSAGES CONTROL] -disable=fixme diff --git a/CHANGELOG.md b/CHANGELOG.md index ff44746..cf7f38d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,29 +1,3 @@ - -# Changelog -All notable changes to WuttaMess will be documented in this file. - -The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) -and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). - -## v0.2.0 (2025-01-14) - -### Feat - -- add `util.get_home_path()` function -- add `is_symlink()` and `set_timezone()` util functions -- add `apt.is_installed()` function -- add basic `postgres` module for db setup -- add `ssh` module with `cache_host_key()` function -- add `util.mako_renderer()` function -- add `util` module with `exists()` function -- add basic postfix config helpers - -### Fix - -- add `wutta.purge_email_settings()` for cloning prod DB to test -- add `postgres.dump_db()` function -- add `sync.make_selector()` convenience function - ## v0.1.0 (2024-09-10) ### Feat diff --git a/docs/api/wuttamess.postfix.rst b/docs/api/wuttamess.postfix.rst deleted file mode 100644 index c5b9132..0000000 --- a/docs/api/wuttamess.postfix.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttamess.postfix`` -===================== - -.. automodule:: wuttamess.postfix - :members: diff --git a/docs/api/wuttamess.postgres.rst b/docs/api/wuttamess.postgres.rst deleted file mode 100644 index 742d239..0000000 --- a/docs/api/wuttamess.postgres.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttamess.postgres`` -====================== - -.. automodule:: wuttamess.postgres - :members: diff --git a/docs/api/wuttamess.ssh.rst b/docs/api/wuttamess.ssh.rst deleted file mode 100644 index 1810230..0000000 --- a/docs/api/wuttamess.ssh.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttamess.ssh`` -================= - -.. automodule:: wuttamess.ssh - :members: diff --git a/docs/api/wuttamess.util.rst b/docs/api/wuttamess.util.rst deleted file mode 100644 index e8813f6..0000000 --- a/docs/api/wuttamess.util.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttamess.util`` -================== - -.. automodule:: wuttamess.util - :members: diff --git a/docs/api/wuttamess.wutta.rst b/docs/api/wuttamess.wutta.rst deleted file mode 100644 index ecb58a5..0000000 --- a/docs/api/wuttamess.wutta.rst +++ /dev/null @@ -1,6 +0,0 @@ - -``wuttamess.wutta`` -=================== - -.. automodule:: wuttamess.wutta - :members: diff --git a/docs/conf.py b/docs/conf.py index bff92da..3256549 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -8,32 +8,32 @@ from importlib.metadata import version as get_version -project = "WuttaMess" -copyright = "2024, Lance Edgar" -author = "Lance Edgar" -release = get_version("WuttaMess") +project = 'WuttaMess' +copyright = '2024, Lance Edgar' +author = 'Lance Edgar' +release = get_version('WuttaMess') # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration extensions = [ - "sphinx.ext.autodoc", - "sphinx.ext.intersphinx", - "sphinx.ext.viewcode", - "sphinx.ext.todo", + 'sphinx.ext.autodoc', + 'sphinx.ext.intersphinx', + 'sphinx.ext.viewcode', + 'sphinx.ext.todo', ] -templates_path = ["_templates"] -exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] intersphinx_mapping = { - "fabsync": ("https://fabsync.ignorare.dev/", None), - "invoke": ("https://docs.pyinvoke.org/en/stable/", None), + 'fabsync': ('https://fabsync.ignorare.dev/', None), + 'invoke': ('https://docs.pyinvoke.org/en/stable/', None), } # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = "furo" -html_static_path = ["_static"] +html_theme = 'furo' +html_static_path = ['_static'] diff --git a/docs/index.rst b/docs/index.rst index a779fbc..3869c2e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -17,12 +17,6 @@ project. .. _test coverage: https://buildbot.rattailproject.org/coverage/wuttamess/ -.. image:: https://img.shields.io/badge/linting-pylint-yellowgreen - :target: https://github.com/pylint-dev/pylint - -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/psf/black - .. toctree:: :maxdepth: 2 @@ -37,9 +31,4 @@ project. api/wuttamess api/wuttamess.apt - api/wuttamess.postfix - api/wuttamess.postgres - api/wuttamess.ssh api/wuttamess.sync - api/wuttamess.util - api/wuttamess.wutta diff --git a/docs/narr/usage.rst b/docs/narr/usage.rst index d1300fd..cc0c89c 100644 --- a/docs/narr/usage.rst +++ b/docs/narr/usage.rst @@ -52,15 +52,12 @@ merely a personal convention. You can define tasks however you need:: """ from fabric import task - from wuttamess import apt, sync, util + from wuttamess import apt, sync # nb. this is used below, for file sync root = sync.make_root('files') - # nb. this is for global mako template context etc. - env = {'machine_is_live': False} - @task def bootstrap_all(c): @@ -77,13 +74,11 @@ merely a personal convention. You can define tasks however you need:: """ Bootstrap the base system """ - renderers = {'mako': util.mako_renderer(c, env)} - apt.dist_upgrade(c) # postfix apt.install(c, 'postfix') - if sync.check_isync(c, root, 'etc/postfix', renderers=renderers): + if sync.check_isync(c, root, 'etc/postfix'): c.run('systemctl restart postfix') diff --git a/pyproject.toml b/pyproject.toml index 7d69a1c..65a85a3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "WuttaMess" -version = "0.2.0" +version = "0.1.0" description = "Fabric Automation Helpers" readme = "README.md" authors = [{name = "Lance Edgar", email = "lance@wuttaproject.org"}] @@ -31,20 +31,17 @@ requires-python = ">= 3.8" dependencies = [ "fabric", "fabsync", - "mako", - "typing_extensions", ] [project.optional-dependencies] docs = ["Sphinx", "furo"] -tests = ["pylint", "pytest", "pytest-cov", "tox"] +tests = ["pytest-cov", "tox"] [project.urls] Homepage = "https://wuttaproject.org/" Repository = "https://forgejo.wuttaproject.org/wutta/wuttamess" -Issues = "https://forgejo.wuttaproject.org/wutta/wuttamess/issues" Changelog = "https://forgejo.wuttaproject.org/wutta/wuttamess/src/branch/master/CHANGELOG.md" diff --git a/src/wuttamess/_version.py b/src/wuttamess/_version.py index c4cb30e..c3d9e4f 100644 --- a/src/wuttamess/_version.py +++ b/src/wuttamess/_version.py @@ -1,9 +1,6 @@ # -*- coding: utf-8; -*- -""" -Package Version -""" from importlib.metadata import version -__version__ = version("WuttaMess") +__version__ = version('WuttaMess') diff --git a/src/wuttamess/apt.py b/src/wuttamess/apt.py index d8351b6..32f526c 100644 --- a/src/wuttamess/apt.py +++ b/src/wuttamess/apt.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttaMess -- Fabric Automation Helpers -# Copyright © 2024-2025 Lance Edgar +# Copyright © 2024 Lance Edgar # # This file is part of Wutta Framework. # @@ -25,7 +25,7 @@ APT package management """ -def dist_upgrade(c, frontend="noninteractive"): +def dist_upgrade(c, frontend='noninteractive'): """ Run a full dist-upgrade for APT. Essentially this runs: @@ -46,22 +46,9 @@ def install(c, *packages, **kwargs): apt install PKG [PKG ...] """ - frontend = kwargs.pop("frontend", "noninteractive") - packages = " ".join(packages) - return c.run(f"DEBIAN_FRONTEND={frontend} apt-get --assume-yes install {packages}") - - -def is_installed(c, package): - """ - Check if the given APT package is installed. - - :param c: Fabric connection. - - :param package: Name of package to be checked. - - :returns: ``True`` if package is installed, else ``False``. - """ - return c.run(f"dpkg-query -s {package}", warn=True).ok + frontend = kwargs.pop('frontend', 'noninteractive') + packages = ' '.join(packages) + return c.run(f'DEBIAN_FRONTEND={frontend} apt-get --assume-yes install {packages}') def update(c): @@ -72,12 +59,10 @@ def update(c): apt update """ - c.run("apt-get update") + c.run('apt-get update') -def upgrade( # pylint: disable=redefined-outer-name - c, dist_upgrade=False, frontend="noninteractive" -): +def upgrade(c, dist_upgrade=False, frontend='noninteractive'): """ Upgrade packages via APT. Essentially this runs: @@ -89,11 +74,8 @@ def upgrade( # pylint: disable=redefined-outer-name apt dist-upgrade """ - options = "" - if frontend == "noninteractive": - options = ( - '--option Dpkg::Options::="--force-confdef" ' - '--option Dpkg::Options::="--force-confold"' - ) - upgrade = "dist-upgrade" if dist_upgrade else "upgrade" - c.run(f"DEBIAN_FRONTEND={frontend} apt-get --assume-yes {options} {upgrade}") + options = '' + if frontend == 'noninteractive': + options = '--option Dpkg::Options::="--force-confdef" --option Dpkg::Options::="--force-confold"' + upgrade = 'dist-upgrade' if dist_upgrade else 'upgrade' + c.run(f'DEBIAN_FRONTEND={frontend} apt-get --assume-yes {options} {upgrade}') diff --git a/src/wuttamess/postfix.py b/src/wuttamess/postfix.py deleted file mode 100644 index d6ef4b9..0000000 --- a/src/wuttamess/postfix.py +++ /dev/null @@ -1,67 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaMess -- Fabric Automation Helpers -# Copyright © 2024 Lance Edgar -# -# This file is part of Wutta Framework. -# -# Wutta Framework is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# Wutta Framework is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# Wutta Framework. If not, see . -# -################################################################################ -""" -Postfix mail service -""" - - -def set_config(c, setting, value): - """ - Configure the given setting with the given value. - """ - c.run(f"postconf -e '{setting}={value}'") - - -def set_myhostname(c, hostname): - """ - Configure the ``myhostname`` setting with the given string. - """ - set_config(c, "myhostname", hostname) - - -def set_myorigin(c, origin): - """ - Configure the ``myorigin`` setting with the given string. - """ - set_config(c, "myorigin", origin) - - -def set_mydestination(c, *destinations): - """ - Configure the ``mydestinations`` setting with the given strings. - """ - set_config(c, "mydestination", ", ".join(destinations)) - - -def set_mynetworks(c, *networks): - """ - Configure the ``mynetworks`` setting with the given strings. - """ - set_config(c, "mynetworks", " ".join(networks)) - - -def set_relayhost(c, relayhost): - """ - Configure the ``relayhost`` setting with the given string - """ - set_config(c, "relayhost", relayhost) diff --git a/src/wuttamess/postgres.py b/src/wuttamess/postgres.py deleted file mode 100644 index 0cd3b97..0000000 --- a/src/wuttamess/postgres.py +++ /dev/null @@ -1,199 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaMess -- Fabric Automation Helpers -# Copyright © 2024-2025 Lance Edgar -# -# This file is part of Wutta Framework. -# -# Wutta Framework is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# Wutta Framework is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# Wutta Framework. If not, see . -# -################################################################################ -""" -PostgreSQL DB utilities -""" - - -def sql(c, sql_, database="", port=None, **kwargs): - """ - Execute some SQL as the ``postgres`` user. - - :param c: Fabric connection. - - :param sql_: SQL string to execute. - - :param database: Name of the database on which to execute the SQL. - If not specified, default ``postgres`` is assumed. - - :param port: Optional port for PostgreSQL; default is 5432. - """ - port = f" --port={port}" if port else "" - return c.sudo( - f'psql{port} --tuples-only --no-align --command="{sql_}" {database}', - user="postgres", - **kwargs, - ) - - -def user_exists(c, name, port=None): - """ - Determine if a given PostgreSQL user exists. - - :param c: Fabric connection. - - :param name: Username to check for. - - :param port: Optional port for PostgreSQL; default is 5432. - - :returns: ``True`` if user exists, else ``False``. - """ - user = sql( - c, f"SELECT rolname FROM pg_roles WHERE rolname = '{name}'", port=port - ).stdout.strip() - return bool(user) - - -def create_user(c, name, password=None, port=None, checkfirst=True): - """ - Create a PostgreSQL user account. - - :param c: Fabric connection. - - :param name: Username to create. - - :param password: Optional password for the new user. If set, will - call :func:`set_user_password()`. - - :param port: Optional port for PostgreSQL; default is 5432. - - :param checkfirst: If true (the default), first check if user - exists and skip creating if already present. If false, then - try to create user with no check. - """ - if not checkfirst or not user_exists(c, name, port=port): - portarg = f" --port={port}" if port else "" - c.sudo( - f"createuser{portarg} --no-createrole --no-superuser {name}", - user="postgres", - ) - if password: - set_user_password(c, name, password, port=port) - - -def set_user_password(c, name, password, port=None): - """ - Set the password for a PostgreSQL user account. - - :param c: Fabric connection. - - :param name: Username whose password is to be set. - - :param password: Password for the new user. - - :param port: Optional port for PostgreSQL; default is 5432. - """ - sql( - c, - f"ALTER USER \\\"{name}\\\" PASSWORD '{password}';", - port=port, - hide=True, - echo=False, - ) - - -def db_exists(c, name, port=None): - """ - Determine if a given PostgreSQL database exists. - - :param c: Fabric connection. - - :param name: Name of the database to check for. - - :param port: Optional port for PostgreSQL; default is 5432. - - :returns: ``True`` if database exists, else ``False``. - """ - db = sql( - c, f"SELECT datname FROM pg_database WHERE datname = '{name}'", port=port - ).stdout.strip() - return db == name - - -def create_db(c, name, owner=None, port=None, checkfirst=True): - """ - Create a PostgreSQL database. - - :param c: Fabric connection. - - :param name: Name of the database to create. - - :param owner: Optional role name to set as owner for the database. - - :param port: Optional port for PostgreSQL; default is 5432. - - :param checkfirst: If true (the default), first check if DB exists - and skip creating if already present. If false, then try to - create DB with no check. - """ - if not checkfirst or not db_exists(c, name, port=port): - port = f" --port={port}" if port else "" - owner = f" --owner={owner}" if owner else "" - c.sudo(f"createdb{port}{owner} {name}", user="postgres") - - -def drop_db(c, name, checkfirst=True): - """ - Drop a PostgreSQL database. - - :param c: Fabric connection. - - :param name: Name of the database to drop. - - :param checkfirst: If true (the default), first check if DB exists - and skip dropping if not present. If false, then try to drop - DB with no check. - """ - if not checkfirst or db_exists(c, name): - c.sudo(f"dropdb {name}", user="postgres") - - -def dump_db(c, name): - """ - Dump a PostgreSQL database to file. - - This uses the ``pg_dump`` and ``gzip`` commands to produce a - compressed SQL dump. The filename returned is based on the - ``name`` provided, e.g. ``mydbname.sql.gz``. - - :param c: Fabric connection. - - :param name: Name of the database to dump. - - :returns: Base name of the output file. We only return the - filename and not the path, since the file is expected to exist - in the connected user's home folder. - """ - sql_name = f"{name}.sql" - gz_name = f"{sql_name}.gz" - tmp_name = f"/tmp/{gz_name}" - - # TODO: when pg_dump fails the command still succeeds! (would this work?) - # cmd = f'set -e && pg_dump {name} | gzip -c > {tmp_name}' - cmd = f"pg_dump {name} | gzip -c > {tmp_name}" - - c.sudo(cmd, user="postgres") - c.run(f"cp {tmp_name} {gz_name}") - c.run(f"rm {tmp_name}") - - return gz_name diff --git a/src/wuttamess/ssh.py b/src/wuttamess/ssh.py deleted file mode 100644 index 89a2b64..0000000 --- a/src/wuttamess/ssh.py +++ /dev/null @@ -1,75 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaMess -- Fabric Automation Helpers -# Copyright © 2024 Lance Edgar -# -# This file is part of Wutta Framework. -# -# Wutta Framework is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# Wutta Framework is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# Wutta Framework. If not, see . -# -################################################################################ -""" -SSH Utilities -""" - - -def cache_host_key(c, host, port=None, user=None): - """ - Cache the SSH host key for the given host, for the given user. - - :param c: Fabric connection. - - :param host: Name or IP of the host whose key should be cached. - - Note that you can specify a username along with the hostname if - needed, e.g. any of these works: - - * ``1.2.3.4`` - * ``foo@1.2.3.4`` - * ``example.com`` - * ``foo@example.com`` - - :param port: Optional SSH port for the ``host``; default is 22. - - :param user: User on the fabric target whose SSH key cache should - be updated to include the given ``host``. - """ - port = f"-p {port} " if port else "" - - # first try to run a basic command over ssh - cmd = f"ssh {port}{host} whoami" - if user and user != "root": - result = c.sudo(cmd, user=user, warn=True) - else: - result = c.run(cmd, warn=True) - - # no need to update cache if command worked okay - if not result.failed: - return - - # basic command failed, but in some cases that is simply b/c - # normal commands are not allowed, although the ssh connection - # itself was established okay. so here we check for that. - if "Disallowed command" in result.stderr: - return - - # okay then we now think that the ssh connection itself - # was not made, which presumably means we *do* need to - # cache the host key, so try that now - cmd = f"ssh -o StrictHostKeyChecking=no {port}{host} whoami" - if user and user != "root": - c.sudo(cmd, user=user, warn=True) - else: - c.run(cmd, warn=True) diff --git a/src/wuttamess/sync.py b/src/wuttamess/sync.py index db566e6..fa48447 100644 --- a/src/wuttamess/sync.py +++ b/src/wuttamess/sync.py @@ -2,7 +2,7 @@ ################################################################################ # # WuttaMess -- Fabric Automation Helpers -# Copyright © 2024-2025 Lance Edgar +# Copyright © 2024 Lance Edgar # # This file is part of Wutta Framework. # @@ -29,7 +29,7 @@ See :doc:`/narr/usage` for a basic example. import fabsync -def make_root(path, dest="/"): +def make_root(path, dest='/'): """ Make and return a "root" object for use with future sync calls. @@ -44,56 +44,32 @@ def make_root(path, dest="/"): return fabsync.load(path, dest) -def make_selector(subpath=None, **kwargs): - """ - Make and return an "item selector" for use with a sync call. - - This is a convenience wrapper around - :meth:`fabsync:fabsync.ItemSelector.new()`. - - :param subpath: (Optional) Relative subpath of the file tree to - sync, e.g. ``'etc/postfix'``. - - :param tags: Optional iterable of tags to include; excluding any - files which are not so tagged. E.g. ``{'foo'}`` - """ - return fabsync.ItemSelector.new(subpath, **kwargs) - - -def isync(c, root, selector=None, tags=None, echo=True, **kwargs): +def isync(c, root, selector=None, echo=True, **kwargs): """ Sync files, yielding the result for each as it goes. This is a convenience wrapper around :func:`fabsync:fabsync.isync()`. - :param c: Fabric connection. + :param c: Connection object. :param root: File tree "root" object as obtained from :func:`make_root()`. :param selector: This can be a simple "subpath" string, indicating - a section of the file tree (e.g. ``'etc/postfix'``). Or can be - a :class:`fabsync.ItemSelector` instance. - - :param tags: Optional iterable of tags to select. If ``selector`` - is a subpath string, and you specify ``tags`` then they will be - included when creating the actual selector. + a section of the file tree. For instance: ``'etc/postfix'`` :param echo: Flag indicating whether the path for each file synced should be echoed to stdout. Generally thought to be useful but may be disabled. - :param \\**kwargs: Any remaining kwargs are passed as-is to + :param \**kwargs: Any remaining kwargs are passed as-is to :func:`fabsync:fabsync.isync()`. """ if selector: if not isinstance(selector, fabsync.ItemSelector): - kw = {} - if tags: - kw["tags"] = tags - selector = make_selector(selector, **kw) - kwargs["selector"] = selector + selector = fabsync.ItemSelector.new(selector) + kwargs['selector'] = selector for result in fabsync.isync(c, root, **kwargs): if echo: @@ -111,6 +87,5 @@ def check_isync(c, root, selector=None, **kwargs): :returns: ``True`` if any sync result indicates a file was modified; otherwise ``False``. """ - return any( # pylint: disable=use-a-generator - [result.modified for result in isync(c, root, selector, **kwargs)] - ) + return any([result.modified + for result in isync(c, root, selector, **kwargs)]) diff --git a/src/wuttamess/util.py b/src/wuttamess/util.py deleted file mode 100644 index a64ab1f..0000000 --- a/src/wuttamess/util.py +++ /dev/null @@ -1,124 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaMess -- Fabric Automation Helpers -# Copyright © 2024-2025 Lance Edgar -# -# This file is part of Wutta Framework. -# -# Wutta Framework is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# Wutta Framework is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# Wutta Framework. If not, see . -# -################################################################################ -""" -Misc. Utilities -""" - -from pathlib import Path -from typing import Mapping -from typing_extensions import Any - -from mako.template import Template - - -def exists(c, path): - """ - Returns ``True`` if given path exists on the host, otherwise ``False``. - """ - return not c.run(f"test -e {path}", warn=True).failed - - -def get_home_path(c, user=None): - """ - Get the path to user's home folder on target machine. - - :param c: Fabric connection. - - :param user: Username whose home folder you want. If not - specified, the username for the current connection is assumed. - - :returns: Home folder path as string. - """ - user = user or c.user - home = c.run(f"getent passwd {user} | cut -d: -f6").stdout.strip() - home = home.rstrip("/") - return home - - -def is_symlink(c, path): - """ - Check if the given path is a symlink. - - :param c: Fabric connection. - - :param path: Path to check, on target machine. - - :returns: ``True`` if path is a symlink, else ``False``. - """ - # nb. this function is derived from one copied from fabric v1 - cmd = f'test -L "$(echo {path})"' - result = c.run(cmd, warn=True) - return not result.failed - - -def mako_renderer(c, env=None): # pylint: disable=unused-argument - """ - This returns a *function* suitable for use as a ``fabsync`` file - renderer. The function assumes the file is a Mako template. - - :param c: Fabric connection. - - :param env: Environment dictionary to be used as Mako template - context. - - Typical usage is something like:: - - from fabric import task - from wuttamess import sync, util - - root = sync.make_root('files') - env = {} - - @task - def foo(c): - - # define possible renderers for fabsync - renderers = {'mako': util.mako_renderer(c, env)} - - sync.check_isync(c, root, 'etc/postfix', renderers=renderers) - """ - env = env or {} - - def render( # pylint: disable=redefined-builtin,unused-argument - path: Path, vars: Mapping[str, Any], **kwargs - ) -> bytes: - return Template(filename=str(path)).render(**env) - - return render - - -def set_timezone(c, timezone): - """ - Set the system timezone. - - :param c: Fabric connection. - - :param timezone: Standard timezone name, - e.g. ``'America/Chicago'``. - """ - c.run(f"bash -c 'echo {timezone} > /etc/timezone'") - - if is_symlink(c, "/etc/localtime"): - c.run(f"ln -sf /usr/share/zoneinfo/{timezone} /etc/localtime") - else: - c.run(f"cp /usr/share/zoneinfo/{timezone} /etc/localtime") diff --git a/src/wuttamess/wutta.py b/src/wuttamess/wutta.py deleted file mode 100644 index 3688e53..0000000 --- a/src/wuttamess/wutta.py +++ /dev/null @@ -1,69 +0,0 @@ -# -*- coding: utf-8; -*- -################################################################################ -# -# WuttaMess -- Fabric Automation Helpers -# Copyright © 2024-2025 Lance Edgar -# -# This file is part of Wutta Framework. -# -# Wutta Framework is free software: you can redistribute it and/or modify it -# under the terms of the GNU General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# Wutta Framework is distributed in the hope that it will be useful, but -# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or -# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for -# more details. -# -# You should have received a copy of the GNU General Public License along with -# Wutta Framework. If not, see . -# -################################################################################ -""" -Utilities for Wutta Framework -""" - -from wuttamess import postgres - - -def purge_email_settings(c, dbname, appname="wutta"): - """ - Purge production email settings for a database. - - This can be used when cloning a production app DB to a test - server. The general pattern is: - - * setup test app on test server - * config file should specify test email settings - * clone the production DB to test server - * call this function to purge email settings from test DB - - So the end result should be, the test server app can run and send - emails safely using only what is specified in config file(s), - since none of the production email settings remain in the test DB. - - :param dbname: Name of the database to be updated. - - :param appname: The ``appname`` used to determine setting names. - """ - postgres.sql( - c, - f"delete from setting where name like '{appname}.email.%.sender';", - database=dbname, - ) - postgres.sql( - c, - f"delete from setting where name like '{appname}.email.%.to';", - database=dbname, - ) - postgres.sql( - c, - f"delete from setting where name like '{appname}.email.%.cc';", - database=dbname, - ) - postgres.sql( - c, - f"delete from setting where name like '{appname}.email.%.bcc';", - database=dbname, - ) diff --git a/tasks.py b/tasks.py deleted file mode 100644 index 49daeb4..0000000 --- a/tasks.py +++ /dev/null @@ -1,24 +0,0 @@ -# -*- coding: utf-8; -*- -""" -Tasks for WuttaMess -""" - -import os -import shutil - -from invoke import task - - -@task -def release(c, skip_tests=False): - """ - Release a new version of WuttaMess - """ - if not skip_tests: - c.run("pytest") - - if os.path.exists("dist"): - shutil.rmtree("dist") - - c.run("python -m build --sdist") - c.run("twine upload dist/*") diff --git a/tests/files/bar/_sync.toml b/tests/files/bar/_sync.toml deleted file mode 100644 index 7bcceb2..0000000 --- a/tests/files/bar/_sync.toml +++ /dev/null @@ -1,4 +0,0 @@ - -[files."baz"] -renderer = 'mako' -tags = ['baz'] diff --git a/tests/files/bar/baz b/tests/files/bar/baz deleted file mode 100644 index 1f33ce5..0000000 --- a/tests/files/bar/baz +++ /dev/null @@ -1 +0,0 @@ -machine_is_live = ${machine_is_live} \ No newline at end of file diff --git a/tests/test_apt.py b/tests/test_apt.py index 367ce05..57f77d9 100644 --- a/tests/test_apt.py +++ b/tests/test_apt.py @@ -10,38 +10,19 @@ class TestDistUpgrade(TestCase): def test_basic(self): c = MagicMock() - with patch.object(mod, "update") as update: - with patch.object(mod, "upgrade") as upgrade: - mod.dist_upgrade(c, frontend="whatever") + with patch.object(mod, 'update') as update: + with patch.object(mod, 'upgrade') as upgrade: + mod.dist_upgrade(c, frontend='whatever') update.assert_called_once_with(c) - upgrade.assert_called_once_with( - c, dist_upgrade=True, frontend="whatever" - ) + upgrade.assert_called_once_with(c, dist_upgrade=True, frontend='whatever') class TestInstall(TestCase): def test_basic(self): c = MagicMock() - mod.install(c, "postfix") - c.run.assert_called_once_with( - "DEBIAN_FRONTEND=noninteractive apt-get --assume-yes install postfix" - ) - - -class TestIsInstalled(TestCase): - - def test_already_installed(self): - c = MagicMock() - c.run.return_value.ok = True - self.assertTrue(mod.is_installed(c, "postfix")) - c.run.assert_called_once_with("dpkg-query -s postfix", warn=True) - - def test_not_installed(self): - c = MagicMock() - c.run.return_value.ok = False - self.assertFalse(mod.is_installed(c, "postfix")) - c.run.assert_called_once_with("dpkg-query -s postfix", warn=True) + mod.install(c, 'postfix') + c.run.assert_called_once_with('DEBIAN_FRONTEND=noninteractive apt-get --assume-yes install postfix') class TestUpdate(TestCase): @@ -49,7 +30,7 @@ class TestUpdate(TestCase): def test_basic(self): c = MagicMock() mod.update(c) - c.run.assert_called_once_with("apt-get update") + c.run.assert_called_once_with('apt-get update') class TestUpgrade(TestCase): @@ -57,6 +38,4 @@ class TestUpgrade(TestCase): def test_basic(self): c = MagicMock() mod.upgrade(c) - c.run.assert_called_once_with( - 'DEBIAN_FRONTEND=noninteractive apt-get --assume-yes --option Dpkg::Options::="--force-confdef" --option Dpkg::Options::="--force-confold" upgrade' - ) + c.run.assert_called_once_with('DEBIAN_FRONTEND=noninteractive apt-get --assume-yes --option Dpkg::Options::="--force-confdef" --option Dpkg::Options::="--force-confold" upgrade') diff --git a/tests/test_postfix.py b/tests/test_postfix.py deleted file mode 100644 index 86e01b1..0000000 --- a/tests/test_postfix.py +++ /dev/null @@ -1,56 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest import TestCase -from unittest.mock import MagicMock - -from wuttamess import postfix as mod - - -class TestSetConfig(TestCase): - - def test_basic(self): - c = MagicMock() - mod.set_config(c, "foo", "bar") - c.run.assert_called_once_with("postconf -e 'foo=bar'") - - -class TestSetMyhostname(TestCase): - - def test_basic(self): - c = MagicMock() - mod.set_myhostname(c, "test.example.com") - c.run.assert_called_once_with("postconf -e 'myhostname=test.example.com'") - - -class TestSetMyorigin(TestCase): - - def test_basic(self): - c = MagicMock() - mod.set_myorigin(c, "example.com") - c.run.assert_called_once_with("postconf -e 'myorigin=example.com'") - - -class TestSetMydestination(TestCase): - - def test_basic(self): - c = MagicMock() - mod.set_mydestination(c, "example.com", "test.example.com", "localhost") - c.run.assert_called_once_with( - "postconf -e 'mydestination=example.com, test.example.com, localhost'" - ) - - -class TestSetMynetworks(TestCase): - - def test_basic(self): - c = MagicMock() - mod.set_mynetworks(c, "127.0.0.0/8", "[::1]/128") - c.run.assert_called_once_with("postconf -e 'mynetworks=127.0.0.0/8 [::1]/128'") - - -class TestSetRelayhost(TestCase): - - def test_basic(self): - c = MagicMock() - mod.set_relayhost(c, "mail.example.com") - c.run.assert_called_once_with("postconf -e 'relayhost=mail.example.com'") diff --git a/tests/test_postgres.py b/tests/test_postgres.py deleted file mode 100644 index bfe59a7..0000000 --- a/tests/test_postgres.py +++ /dev/null @@ -1,153 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest import TestCase -from unittest.mock import MagicMock, patch - -from wuttamess import postgres as mod - - -class TestSql(TestCase): - - def test_basic(self): - c = MagicMock() - mod.sql(c, "select @@version") - c.sudo.assert_called_once_with( - 'psql --tuples-only --no-align --command="select @@version" ', - user="postgres", - ) - - -class TestUserExists(TestCase): - - def test_user_exists(self): - c = MagicMock() - with patch.object(mod, "sql") as sql: - sql.return_value.stdout = "foo" - self.assertTrue(mod.user_exists(c, "foo")) - sql.assert_called_once_with( - c, "SELECT rolname FROM pg_roles WHERE rolname = 'foo'", port=None - ) - - def test_user_does_not_exist(self): - c = MagicMock() - with patch.object(mod, "sql") as sql: - sql.return_value.stdout = "" - self.assertFalse(mod.user_exists(c, "foo")) - sql.assert_called_once_with( - c, "SELECT rolname FROM pg_roles WHERE rolname = 'foo'", port=None - ) - - -class TestCreateUser(TestCase): - - def test_basic(self): - c = MagicMock() - with patch.object(mod, "set_user_password") as set_user_password: - mod.create_user(c, "foo", checkfirst=False) - c.sudo.assert_called_once_with( - "createuser --no-createrole --no-superuser foo", user="postgres" - ) - set_user_password.assert_not_called() - - def test_user_exists(self): - c = MagicMock() - - with patch.object(mod, "user_exists") as user_exists: - user_exists.return_value = True - - mod.create_user(c, "foo") - user_exists.assert_called_once_with(c, "foo", port=None) - c.sudo.assert_not_called() - - def test_with_password(self): - c = MagicMock() - with patch.object(mod, "set_user_password") as set_user_password: - mod.create_user(c, "foo", "foopass", checkfirst=False) - c.sudo.assert_called_once_with( - "createuser --no-createrole --no-superuser foo", user="postgres" - ) - set_user_password.assert_called_once_with(c, "foo", "foopass", port=None) - - -class TestSetUserPassword(TestCase): - - def test_basic(self): - c = MagicMock() - with patch.object(mod, "sql") as sql: - mod.set_user_password(c, "foo", "foopass") - sql.assert_called_once_with( - c, - "ALTER USER \\\"foo\\\" PASSWORD 'foopass';", - port=None, - hide=True, - echo=False, - ) - - -class TestDbExists(TestCase): - - def test_db_exists(self): - c = MagicMock() - with patch.object(mod, "sql") as sql: - sql.return_value.stdout = "foo" - self.assertTrue(mod.db_exists(c, "foo")) - sql.assert_called_once_with( - c, "SELECT datname FROM pg_database WHERE datname = 'foo'", port=None - ) - - def test_db_does_not_exist(self): - c = MagicMock() - with patch.object(mod, "sql") as sql: - sql.return_value.stdout = "" - self.assertFalse(mod.db_exists(c, "foo")) - sql.assert_called_once_with( - c, "SELECT datname FROM pg_database WHERE datname = 'foo'", port=None - ) - - -class TestCreateDb(TestCase): - - def test_basic(self): - c = MagicMock() - mod.create_db(c, "foo", checkfirst=False) - c.sudo.assert_called_once_with("createdb foo", user="postgres") - - def test_db_exists(self): - c = MagicMock() - - with patch.object(mod, "db_exists") as db_exists: - db_exists.return_value = True - - mod.create_db(c, "foo") - db_exists.assert_called_once_with(c, "foo", port=None) - c.sudo.assert_not_called() - - -class TestDropDb(TestCase): - - def test_basic(self): - c = MagicMock() - mod.drop_db(c, "foo", checkfirst=False) - c.sudo.assert_called_once_with("dropdb foo", user="postgres") - - def test_db_does_not_exist(self): - c = MagicMock() - - with patch.object(mod, "db_exists") as db_exists: - db_exists.return_value = False - - mod.drop_db(c, "foo") - db_exists.assert_called_once_with(c, "foo") - c.sudo.assert_not_called() - - -class TestDumpDb(TestCase): - - def test_basic(self): - c = MagicMock() - result = mod.dump_db(c, "foo") - self.assertEqual(result, "foo.sql.gz") - c.sudo.assert_called_once_with( - "pg_dump foo | gzip -c > /tmp/foo.sql.gz", user="postgres" - ) - c.run.assert_called_with("rm /tmp/foo.sql.gz") diff --git a/tests/test_ssh.py b/tests/test_ssh.py deleted file mode 100644 index bee1574..0000000 --- a/tests/test_ssh.py +++ /dev/null @@ -1,65 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest import TestCase -from unittest.mock import MagicMock, call - -from wuttamess import ssh as mod - - -class TestCacheHostKey(TestCase): - - def test_root_already_cached(self): - c = MagicMock() - - # assume the first command runs okay - c.run.return_value.failed = False - mod.cache_host_key(c, "example.com") - c.run.assert_called_once_with("ssh example.com whoami", warn=True) - - def test_root_commands_not_allowed(self): - c = MagicMock() - - # assume the first command fails b/c "disallowed" - c.run.return_value.failed = True - c.run.return_value.stderr = "Disallowed command" - mod.cache_host_key(c, "example.com") - c.run.assert_called_once_with("ssh example.com whoami", warn=True) - - def test_root_cache_key(self): - c = MagicMock() - - # first command fails; second command caches host key - c.run.return_value.failed = True - mod.cache_host_key(c, "example.com") - c.run.assert_has_calls([call("ssh example.com whoami", warn=True)]) - c.run.assert_called_with( - "ssh -o StrictHostKeyChecking=no example.com whoami", warn=True - ) - - def test_user_already_cached(self): - c = MagicMock() - - # assume the first command runs okay - c.sudo.return_value.failed = False - mod.cache_host_key(c, "example.com", user="foo") - c.sudo.assert_called_once_with("ssh example.com whoami", user="foo", warn=True) - - def test_user_commands_not_allowed(self): - c = MagicMock() - - # assume the first command fails b/c "disallowed" - c.sudo.return_value.failed = True - c.sudo.return_value.stderr = "Disallowed command" - mod.cache_host_key(c, "example.com", user="foo") - c.sudo.assert_called_once_with("ssh example.com whoami", user="foo", warn=True) - - def test_user_cache_key(self): - c = MagicMock() - - # first command fails; second command caches host key - c.sudo.return_value.failed = True - mod.cache_host_key(c, "example.com", user="foo") - c.sudo.assert_has_calls([call("ssh example.com whoami", user="foo", warn=True)]) - c.sudo.assert_called_with( - "ssh -o StrictHostKeyChecking=no example.com whoami", user="foo", warn=True - ) diff --git a/tests/test_sync.py b/tests/test_sync.py index 8d91a05..23f4714 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -12,26 +12,18 @@ from wuttamess import sync as mod class TestMakeRoot(TestCase): def test_basic(self): - root = mod.make_root("files") + root = mod.make_root('files') self.assertIsInstance(root, SyncedRoot) - self.assertEqual(root.src, Path("files")) - self.assertEqual(root.dest, Path("/")) - - -class TestMakeSelector(TestCase): - - def test_basic(self): - selector = mod.make_selector("etc/postfix") - self.assertIsInstance(selector, ItemSelector) - self.assertEqual(selector.subpath, Path("etc/postfix")) + self.assertEqual(root.src, Path('files')) + self.assertEqual(root.dest, Path('/')) class TestIsync(TestCase): def test_basic(self): c = MagicMock() - root = mod.make_root("files") - with patch.object(mod, "fabsync") as fabsync: + root = mod.make_root('files') + with patch.object(mod, 'fabsync') as fabsync: fabsync.ItemSelector = ItemSelector # nothing to sync @@ -42,46 +34,34 @@ class TestIsync(TestCase): # sync one file fabsync.isync.reset_mock() - result = MagicMock(path="/foo", modified=True) + result = MagicMock(path='/foo', modified=True) fabsync.isync.return_value = [result] results = list(mod.isync(c, root)) self.assertEqual(results, [result]) fabsync.isync.assert_called_once_with(c, root) - # sync with selector (subpath) + # sync with selector fabsync.isync.reset_mock() - result = MagicMock(path="/foo", modified=True) + result = MagicMock(path='/foo', modified=True) fabsync.isync.return_value = [result] - results = list(mod.isync(c, root, "foo")) + results = list(mod.isync(c, root, 'foo')) self.assertEqual(results, [result]) - fabsync.isync.assert_called_once_with( - c, root, selector=fabsync.ItemSelector.new("foo") - ) - - # sync with selector (subpath + tags) - fabsync.isync.reset_mock() - result = MagicMock(path="/foo", modified=True) - fabsync.isync.return_value = [result] - results = list(mod.isync(c, root, "foo", tags={"bar"})) - self.assertEqual(results, [result]) - fabsync.isync.assert_called_once_with( - c, root, selector=fabsync.ItemSelector.new("foo", tags={"bar"}) - ) + fabsync.isync.assert_called_once_with(c, root, selector=fabsync.ItemSelector.new('foo')) class TestCheckIsync(TestCase): def test_basic(self): c = MagicMock() - root = mod.make_root("files") - with patch.object(mod, "isync") as isync: + root = mod.make_root('files') + with patch.object(mod, 'isync') as isync: # file(s) modified - result = MagicMock(path="/foo", modified=True) + result = MagicMock(path='/foo', modified=True) isync.return_value = [result] self.assertTrue(mod.check_isync(c, root)) # not modified - result = MagicMock(path="/foo", modified=False) + result = MagicMock(path='/foo', modified=False) isync.return_value = [result] self.assertFalse(mod.check_isync(c, root)) diff --git a/tests/test_util.py b/tests/test_util.py deleted file mode 100644 index 7859634..0000000 --- a/tests/test_util.py +++ /dev/null @@ -1,81 +0,0 @@ -# -*- coding: utf-8; -*- - -import os -from unittest import TestCase -from unittest.mock import MagicMock, patch, call - -from wuttamess import util as mod - - -class TestExists(TestCase): - - def test_basic(self): - c = MagicMock() - mod.exists(c, "/foo") - c.run.assert_called_once_with("test -e /foo", warn=True) - - -class TestHomePath(TestCase): - - def test_basic(self): - c = MagicMock() - c.run.return_value.stdout = "/home/foo" - path = mod.get_home_path(c, user="foo") - self.assertEqual(path, "/home/foo") - - -class TestIsSymlink(TestCase): - - def test_yes(self): - c = MagicMock() - c.run.return_value.failed = False - self.assertTrue(mod.is_symlink(c, "/foo")) - c.run.assert_called_once_with('test -L "$(echo /foo)"', warn=True) - - def test_no(self): - c = MagicMock() - c.run.return_value.failed = True - self.assertFalse(mod.is_symlink(c, "/foo")) - c.run.assert_called_once_with('test -L "$(echo /foo)"', warn=True) - - -class TestMakoRenderer(TestCase): - - def test_basic(self): - c = MagicMock() - renderer = mod.mako_renderer(c, env={"machine_is_live": True}) - here = os.path.dirname(__file__) - path = os.path.join(here, "files", "bar", "baz") - rendered = renderer(path, vars={}) - self.assertEqual(rendered, "machine_is_live = True") - - -class TestSetTimezone(TestCase): - - def test_symlink(self): - c = MagicMock() - with patch.object(mod, "is_symlink") as is_symlink: - is_symlink.return_value = True - mod.set_timezone(c, "America/Chicago") - c.run.assert_has_calls( - [ - call("bash -c 'echo America/Chicago > /etc/timezone'"), - ] - ) - c.run.assert_called_with( - "ln -sf /usr/share/zoneinfo/America/Chicago /etc/localtime" - ) - - def test_not_symlink(self): - c = MagicMock() - with patch.object(mod, "is_symlink") as is_symlink: - is_symlink.return_value = False - mod.set_timezone(c, "America/Chicago") - c.run.assert_has_calls( - [ - call("bash -c 'echo America/Chicago > /etc/timezone'"), - ] - ) - c.run.assert_called_with( - "cp /usr/share/zoneinfo/America/Chicago /etc/localtime" - ) diff --git a/tests/test_wutta.py b/tests/test_wutta.py deleted file mode 100644 index ca08919..0000000 --- a/tests/test_wutta.py +++ /dev/null @@ -1,40 +0,0 @@ -# -*- coding: utf-8; -*- - -from unittest import TestCase -from unittest.mock import MagicMock, patch, call - -from wuttamess import wutta as mod - - -class TestPurgeEmailSettings(TestCase): - - def test_basic(self): - c = MagicMock() - sql = MagicMock() - postgres = MagicMock(sql=sql) - with patch.object(mod, "postgres", new=postgres): - mod.purge_email_settings(c, "testy", appname="wuttatest") - sql.assert_has_calls( - [ - call( - c, - "delete from setting where name like 'wuttatest.email.%.sender';", - database="testy", - ), - call( - c, - "delete from setting where name like 'wuttatest.email.%.to';", - database="testy", - ), - call( - c, - "delete from setting where name like 'wuttatest.email.%.cc';", - database="testy", - ), - call( - c, - "delete from setting where name like 'wuttatest.email.%.bcc';", - database="testy", - ), - ] - ) diff --git a/tox.ini b/tox.ini index ce3b1da..63cbdde 100644 --- a/tox.ini +++ b/tox.ini @@ -6,10 +6,6 @@ envlist = py38, py39, py310, py311 extras = tests commands = pytest {posargs} -[testenv:pylint] -basepython = python3.11 -commands = pylint wuttamess - [testenv:coverage] basepython = python3.11 commands = pytest --cov=wuttamess --cov-report=html --cov-fail-under=100