From 7c5bef76c50121e2171e0e67feff2fb46b2a3a56 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 21 Dec 2020 23:03:35 -0500 Subject: Created a helper for loading environment entries from a directory. --- .gitignore | 2 + .pylintrc | 16 ++++++++ LICENSE.md | 6 +++ README.md | 38 +++++++++++++++++ dev-requirements.in | 3 ++ dev-requirements.txt | 26 ++++++++++++ envdir/__init__.py | 0 envdir/cli.py | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 21 ++++++++++ tools/check | 10 +++++ tools/check-lint | 10 +++++ tools/check-style | 10 +++++ 12 files changed, 255 insertions(+) create mode 100644 .gitignore create mode 100644 .pylintrc create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 dev-requirements.in create mode 100644 dev-requirements.txt create mode 100644 envdir/__init__.py create mode 100644 envdir/cli.py create mode 100644 setup.py create mode 100755 tools/check create mode 100755 tools/check-lint create mode 100755 tools/check-style diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..66dbf51 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.egg-info +*.pyc diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..c590644 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,16 @@ +[MASTER] +# No lint failures allowed. +fail-under=10.0 + +# Prevent litter in ~/.pylint.d +persistent=no + +[MESSAGES CONTROL] +disable= + # None of the modules in this are designed for import. This is a tool, + # with no library attached. + missing-module-docstring, + # I fundamentally disagree with pylint's assertion that stdlib imports go + # before library imports. All libraries are created more or less equal; + # the stdlib merely happens to come with the language. + wrong-import-order, diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..dbfbc85 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,6 @@ +# License + +(c) Owen Jacobson 2020. All rights reserved. + +You can freely install use this software, as described in README.md, for +personal use only. For all other use or redistribution, contact me. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a3bcd77 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Environment Directory Helper + +This program loads environment variables from files. + +The program was motivated by the pattern of configuring various tokens via +environment variables. I found my shell profile increasingly littered with code +of the form: + + export SOME_TOKEN="$(< ~/.some_token)" + +I've replaced all of that with a single line: + + eval "$(envdir-helper)" + +## Security + +As alluded to above, one of the use cases for this is env-specific tokens. These +kinds of tokens deserve special care - not just with this program, but in +general: + +* They should be in files readable only by the current user (`-rw-------`) or by + the current user and group (`-rw-r-----`), as appropriate; +* They should be rotated regularly; and +* They should only be set when in use. + +This program does relatively little to manage this directly. One approach that helps is to invoke `envdir-helper` from [`direnv`] or similar, instead of from your shell profile, and to store the actual tokens in a system such as [Vault] or in the [macOS Keychain] to avoid leaving them on disk. Program entries in the environment directory can retrieve data from outside sources. + +[`direnv`]: https://direnv.net/ +[Vault]: https://www.vaultproject.io/ +[macOS Keychain]: https://developer.apple.com/documentation/security/keychain_services/keychain_items/searching_for_keychain_items + +## Installation + +Some familiarity with Python is assumed, here: + +* Make a virtual environment; +* `$VIRTUALENV/bin/pip install git+https://github.com/ojacobson/envdir-helper/#egg=envdir-helper`; and +* Add its `bin` directory to `PATH` by other means, or invoke it by full path. diff --git a/dev-requirements.in b/dev-requirements.in new file mode 100644 index 0000000..60ced2c --- /dev/null +++ b/dev-requirements.in @@ -0,0 +1,3 @@ +black +pylint +pip-tools diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 0000000..a8f9e72 --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,26 @@ +# +# This file is autogenerated by pip-compile +# To update, run: +# +# pip-compile dev-requirements.in +# +appdirs==1.4.4 # via black +astroid==2.4.2 # via pylint +black==20.8b1 # via -r dev-requirements.in +click==7.1.2 # via black, pip-tools +isort==5.6.4 # via pylint +lazy-object-proxy==1.4.3 # via astroid +mccabe==0.6.1 # via pylint +mypy-extensions==0.4.3 # via black +pathspec==0.8.1 # via black +pip-tools==5.4.0 # via -r dev-requirements.in +pylint==2.6.0 # via -r dev-requirements.in +regex==2020.11.13 # via black +six==1.15.0 # via astroid, pip-tools +toml==0.10.2 # via black, pylint +typed-ast==1.4.1 # via black +typing-extensions==3.7.4.3 # via black +wrapt==1.12.1 # via astroid + +# The following packages are considered to be unsafe in a requirements file: +# pip diff --git a/envdir/__init__.py b/envdir/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/envdir/cli.py b/envdir/cli.py new file mode 100644 index 0000000..9f9db15 --- /dev/null +++ b/envdir/cli.py @@ -0,0 +1,113 @@ +import click +import os +import os.path as op +import pathlib +import shlex +import stat +import subprocess as sp + + +@click.command() +@click.pass_context +@click.argument( + "envdir", + default=pathlib.Path.home() / ".envdir", + metavar="DIR", +) +def main(context, envdir): + r"""Load environment variables from DIR (or ~/.envdir). + + For each non-directory entry in DIR, this will output a brief shell script + to set an environment with the same name. If the entry is an executable + program, it will be run (with the current environment) and its output will + be used as the value of the environment variable. Otherwise, it will be + read, and its content will be used. In either case, a single trailing + newline will be removed, if present. + + This process skips any file which can't be run or read, as appropriate, and + outputs a warning on stderr. + + The intended use case for this is in shell profiles, in the form: + + eval "$(envdir-helper)" + + The generated output is compatible with sh, and thus with bash and zsh. + """ + + def warn_on_skipped(path, reason): + """Log any skipped paths to stderr.""" + stderr = click.get_text_stream("stderr") + click.echo(f"{context.info_name}: skipping {path}: {reason}", file=stderr) + + for name, content in walk_entries(envdir, on_skipped=warn_on_skipped): + script = env_script(name, content) + click.echo(script) + + +def env_script(name, content): + """Given a name and contents, generate a shell script that will set and + export the corresponding environment variable, with that content.""" + # use sh-friendly syntax here: don't assume `export` can have assignment + # side effects, so don't use `export FOO=BAR`. `FOO=BAR; export FOO` is + # portable to all posix shells. + qname = shlex.quote(name) + qcontent = shlex.quote(content) + return f"{qname}={qcontent}; export {qname}" + + +def walk_entries(envdir, on_skipped): + """Yields a name, value pair for each environment file in envdir. + + The path for any skipped items (generally, failing or unrunnable programs, + or unreadable files) will be passed to the `on_skipped` callback, along with + the exception that caused it to be skipped. + """ + for name in sorted(os.listdir(envdir)): + path = op.join(envdir, name) + try: + path_stat = os.stat(path) + if directory(path_stat): + continue + + if executable(path_stat): + content = from_program(path) + else: + content = from_file(path) + except Exception as reason: # pylint: disable=broad-except + on_skipped(path, reason) + continue + if content.endswith("\n"): + content = content[:-1] + yield name, content + + +def directory(item): + """True iff item is a stat_result representing a directory.""" + return stat.S_ISDIR(item.st_mode) + + +def executable(item): + """True iff item is a stat_result representing something executable. + + This doesn't distinguish between dirs and files; both are considered + executable if any +x bit is set. + """ + mode = stat.S_IMODE(item.st_mode) + mask = stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH + return mode & mask != 0 + + +def from_program(path): + """Reads a program's complete stdout into a string.""" + result = sp.run( + [path], + check=True, + stdout=sp.PIPE, + ) + return result.stdout.decode("UTF-8") + + +def from_file(path): + """Reads a file's complete content into a string.""" + with open(path, "r") as file: + return file.read() diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..0d4c69a --- /dev/null +++ b/setup.py @@ -0,0 +1,21 @@ +from setuptools import setup, find_packages + +setup( + name="envdir-helper", + version="0.0.0", + + author="Owen Jacobson", + author_email="owen@grimoire.ca", + + packages=find_packages(), + + install_requires=[ + "click ~= 7.1.0", + ], + + entry_points={ + "console_scripts": [ + "envdir-helper=envdir.cli:main", + ], + }, +) diff --git a/tools/check b/tools/check new file mode 100755 index 0000000..269a23d --- /dev/null +++ b/tools/check @@ -0,0 +1,10 @@ +#!/bin/bash -e + +cd "$(dirname "$0")/.." + +## tools/check +## +## Run checks on the project, exiting with a zero status if all checks pass. + +tools/check-lint +tools/check-style diff --git a/tools/check-lint b/tools/check-lint new file mode 100755 index 0000000..eaebdf0 --- /dev/null +++ b/tools/check-lint @@ -0,0 +1,10 @@ +#!/bin/bash -e + +cd "$(dirname "$0")/.." + +## tools/check-lint +## +## Run lint checks on the project, exiting with a zero status if there are no lint +## issues. + +exec pylint envdir diff --git a/tools/check-style b/tools/check-style new file mode 100755 index 0000000..adb2499 --- /dev/null +++ b/tools/check-style @@ -0,0 +1,10 @@ +#!/bin/bash -e + +cd "$(dirname "$0")/.." + +## tools/check-style +## +## Run style checks on the project, exiting with a zero status if there are no style +## issues. + +exec black --check --diff envdir -- cgit v1.2.3