Compare commits
33 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
9520868cc3 | ||
|
3aebb211cf | ||
|
a178d0ed7c | ||
|
cd3bd67783 | ||
|
b6187f4b1a | ||
|
082892101e | ||
|
fd75435a9c | ||
|
af2a228310 | ||
|
dcf23b8c35 | ||
|
3ff41797c2 | ||
|
2cd0c430cc | ||
|
3d76c710cc | ||
|
3e03c91cb3 | ||
|
fc33fd7a78 | ||
|
cb5d6fc77d | ||
|
b3c64f0403 | ||
|
49be06d41f | ||
|
1adab407e8 | ||
|
58b09694df | ||
|
9c692f2335 | ||
|
324d58690d | ||
|
3eade299a0 | ||
|
b69bfa28b4 | ||
|
c69bdec4c9 | ||
|
0806b4e134 | ||
|
7ee473c9b6 | ||
|
b58b9c3296 | ||
|
c6608c5568 | ||
|
bba4ec271d | ||
|
e5127602f0 | ||
|
2c894b5d1d | ||
|
e5df45650d | ||
|
b250604df5 |
28
.drone.yml
Normal file
28
.drone.yml
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
---
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- IMAGE: 3.6-stretch
|
||||||
|
TOXENV: py36
|
||||||
|
- IMAGE: 3.7-stretch
|
||||||
|
TOXENV: py37
|
||||||
|
- IMAGE: 3.8-buster
|
||||||
|
TOXENV: py38
|
||||||
|
- IMAGE: 3.8-buster
|
||||||
|
TOXENV: lint
|
||||||
|
- IMAGE: 3.8-buster
|
||||||
|
TOXENV: sort
|
||||||
|
- IMAGE: 3.8-buster
|
||||||
|
TOXENV: format
|
||||||
|
- IMAGE: 3.8-buster
|
||||||
|
TOXENV: type
|
||||||
|
- IMAGE: 3.8-buster
|
||||||
|
TOXENV: docs
|
||||||
|
- IMAGE: 3.8-buster
|
||||||
|
TOXENV: metadata-release
|
||||||
|
|
||||||
|
pipeline:
|
||||||
|
build:
|
||||||
|
image: python:${IMAGE}
|
||||||
|
commands:
|
||||||
|
- pip install tox==3.14.6
|
||||||
|
- tox -e ${TOXENV}
|
2
.envrc.sample
Normal file
2
.envrc.sample
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
# The path to our pass credentials store
|
||||||
|
export PASSWORD_STORE_DIR=$(pwd)/../infrastructure/credentials/password-store
|
|
@ -0,0 +1,42 @@
|
||||||
|
Autonomic 0.0.5 (2020-04-13)
|
||||||
|
============================
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
|
||||||
|
- Add CoopHost decrypt command. (#5)
|
||||||
|
|
||||||
|
|
||||||
|
Autonomic 0.0.4 (2020-04-12)
|
||||||
|
============================
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
|
||||||
|
- Add CoopHost / CoopPass (WIP) commands. (#4)
|
||||||
|
- Further sanity checks. (#5)
|
||||||
|
|
||||||
|
|
||||||
|
Autonomic 0.0.3 (2020-04-11)
|
||||||
|
============================
|
||||||
|
|
||||||
|
Features
|
||||||
|
--------
|
||||||
|
|
||||||
|
- Add init command. (#2)
|
||||||
|
- Add Ansible actions command. (#3)
|
||||||
|
|
||||||
|
|
||||||
|
Autonomic 0.0.2 (2020-04-11)
|
||||||
|
============================
|
||||||
|
|
||||||
|
- Woops, missed a tag!
|
||||||
|
|
||||||
|
|
||||||
|
Autonomic 0.0.1 (2020-04-03)
|
||||||
|
============================
|
||||||
|
|
||||||
|
Project announcements
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
- Initial project release! (#1)
|
|
@ -20,3 +20,10 @@ autonomic
|
||||||
|
|
||||||
Command line utility belt for Autonomic
|
Command line utility belt for Autonomic
|
||||||
---------------------------------------
|
---------------------------------------
|
||||||
|
|
||||||
|
.. _documentation:
|
||||||
|
|
||||||
|
Documentation
|
||||||
|
-------------
|
||||||
|
|
||||||
|
Documentation coming soon TM.
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
"""autonomic module."""
|
"""Autonomic module."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import pkg_resources
|
import pkg_resources
|
||||||
|
@ -7,6 +7,6 @@ except ImportError:
|
||||||
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
__version__ = pkg_resources.get_distribution('autonomic').version
|
__version__ = pkg_resources.get_distribution("autonomic").version
|
||||||
except Exception:
|
except Exception:
|
||||||
__version__ = 'unknown'
|
__version__ = "unknown"
|
||||||
|
|
|
@ -2,9 +2,12 @@
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
|
from autonomic.command import actions, coophost, cooppaas, init
|
||||||
|
|
||||||
@click.command()
|
|
||||||
def main():
|
@click.group()
|
||||||
|
@click.pass_context
|
||||||
|
def autonomic(ctx):
|
||||||
"""
|
"""
|
||||||
\b
|
\b
|
||||||
___ _ _
|
___ _ _
|
||||||
|
@ -13,9 +16,14 @@ def main():
|
||||||
| _ | | | | __/ _ \| '_ \ / _ \| '_ ` _ \| |/ __|
|
| _ | | | | __/ _ \| '_ \ / _ \| '_ ` _ \| |/ __|
|
||||||
| | | | |_| | || (_) | | | | (_) | | | | | | | (__
|
| | | | |_| | || (_) | | | | (_) | | | | | | | (__
|
||||||
\_| |_/\__,_|\__\___/|_| |_|\___/|_| |_| |_|_|\___|
|
\_| |_/\__,_|\__\___/|_| |_|\___/|_| |_| |_|_|\___|
|
||||||
|
|
||||||
|
Hack the planet!
|
||||||
|
|
||||||
""" # noqa
|
""" # noqa
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
autonomic.add_command(init.init)
|
||||||
main()
|
autonomic.add_command(actions.actions)
|
||||||
|
autonomic.add_command(coophost.coophost)
|
||||||
|
autonomic.add_command(cooppaas.cooppaas)
|
||||||
|
|
38
autonomic/command/actions.py
Normal file
38
autonomic/command/actions.py
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
"""Actions module."""
|
||||||
|
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from autonomic.config import ACTIONS_DIR, INFRA_DIR, PASS_STORE_DIR
|
||||||
|
from autonomic.infra import get_passwd, run_play
|
||||||
|
from autonomic.settings import get
|
||||||
|
from autonomic.utils import ensure_config_dir, git_status, question_ask
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.pass_context
|
||||||
|
def actions(ctx):
|
||||||
|
"""Run an Ansible action."""
|
||||||
|
ensure_config_dir()
|
||||||
|
|
||||||
|
env = environ.copy()
|
||||||
|
|
||||||
|
env.update({"ANSIBLE_USER": get("username")})
|
||||||
|
env.update({"REMOTE_USER": get("username")})
|
||||||
|
env.update({"PASSWORD_STORE_DIR": PASS_STORE_DIR})
|
||||||
|
|
||||||
|
choices = ["addusers", "newhetzner", "rmhetzner", "newdokku", "pingall"]
|
||||||
|
action = question_ask("action", "Which Ansible action?", choices)
|
||||||
|
|
||||||
|
if any(action in choice for choice in ["newhetzner", "rmhetzner"]):
|
||||||
|
choices = ["prod", "test", "cicd"]
|
||||||
|
key = question_ask("key", "Which Hetzner API key?", choices)
|
||||||
|
|
||||||
|
path = "logins/hetzner/{}/api_key".format(key)
|
||||||
|
secret = get_passwd(path)
|
||||||
|
env["HCLOUD_TOKEN"] = secret
|
||||||
|
|
||||||
|
play = "{}/{}.yml".format(ACTIONS_DIR, action)
|
||||||
|
run_play(play, env=env)
|
||||||
|
git_status(INFRA_DIR)
|
136
autonomic/command/coophost.py
Normal file
136
autonomic/command/coophost.py
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
"""CoopHost module."""
|
||||||
|
|
||||||
|
from os import chdir, mkdir
|
||||||
|
from os.path import basename, exists
|
||||||
|
from pathlib import Path
|
||||||
|
from socket import gethostname
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from autonomic.config import CONFIG_YAML, INFRA_DIR
|
||||||
|
from autonomic.logger import log
|
||||||
|
from autonomic.settings import add, get
|
||||||
|
from autonomic.utils import (
|
||||||
|
ensure_config_dir,
|
||||||
|
ensure_deploy_d_dir,
|
||||||
|
exit,
|
||||||
|
input_ask,
|
||||||
|
pass_ask,
|
||||||
|
question_ask,
|
||||||
|
run,
|
||||||
|
yaml_dump,
|
||||||
|
yaml_load,
|
||||||
|
)
|
||||||
|
|
||||||
|
hostname = gethostname()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.pass_context
|
||||||
|
def coophost(ctx):
|
||||||
|
"""Manage CoopHost resources."""
|
||||||
|
ensure_config_dir()
|
||||||
|
ensure_deploy_d_dir()
|
||||||
|
|
||||||
|
app_dir = Path(".").absolute()
|
||||||
|
app = basename(app_dir)
|
||||||
|
log.info("Auto-detected the {} application".format(app))
|
||||||
|
|
||||||
|
choices = ["encrypt", "decrypt"]
|
||||||
|
operation = question_ask("operation", "Which operation?", choices)
|
||||||
|
|
||||||
|
if operation == "encrypt":
|
||||||
|
encrypt(app, app_dir)
|
||||||
|
elif operation == "decrypt":
|
||||||
|
decrypt(app, app_dir)
|
||||||
|
|
||||||
|
|
||||||
|
def get_vault_pass(app):
|
||||||
|
"""Retrieve or set the app vault password."""
|
||||||
|
app_settings = get(app)
|
||||||
|
|
||||||
|
if app_settings is not None and "vault-password" in app_settings:
|
||||||
|
log.info("Using app vault password stored in {}".format(CONFIG_YAML))
|
||||||
|
return app_settings["vault-password"]
|
||||||
|
|
||||||
|
log.info("No app vault password configured")
|
||||||
|
vault_password = pass_ask("Vault password?")
|
||||||
|
|
||||||
|
log.info("App vault password stored in {}".format(CONFIG_YAML))
|
||||||
|
add({app: {"vault-password": vault_password}})
|
||||||
|
|
||||||
|
return vault_password
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt(app, app_dir):
|
||||||
|
"""Decrypt a secret."""
|
||||||
|
vault_password = get_vault_pass(app)
|
||||||
|
name = input_ask("Which variable do you want to decrypt?")
|
||||||
|
|
||||||
|
vault_path = (Path(".") / "deploy.d" / "vault").absolute()
|
||||||
|
var_path = (vault_path / "{}.yml".format(name)).absolute()
|
||||||
|
|
||||||
|
if not exists(var_path):
|
||||||
|
exit("{}.yml is missing?".format(name))
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
".venv/bin/ansible",
|
||||||
|
hostname,
|
||||||
|
"--inventory",
|
||||||
|
"{},".format(hostname),
|
||||||
|
"-m",
|
||||||
|
"debug",
|
||||||
|
"-a",
|
||||||
|
"var='{}'".format(name),
|
||||||
|
"-e @{}".format(var_path),
|
||||||
|
"--ask-vault-pass",
|
||||||
|
"-e",
|
||||||
|
"ansible_user={}".format(get("username")),
|
||||||
|
]
|
||||||
|
|
||||||
|
decrypted = run(
|
||||||
|
cmd,
|
||||||
|
cwd=INFRA_DIR,
|
||||||
|
output=True,
|
||||||
|
pexpect=True,
|
||||||
|
pexpected={"(?i)vault password:": vault_password},
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info(decrypted)
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt(app, app_dir):
|
||||||
|
"""Encrypt a secret for a CoopHost package."""
|
||||||
|
vault_password = get_vault_pass(app)
|
||||||
|
name = input_ask("Which variable do you want to encrypt?")
|
||||||
|
value = pass_ask("Variable value to encrypt?")
|
||||||
|
|
||||||
|
cmd = [".venv/bin/ansible-vault", "encrypt_string", "--name", name, value]
|
||||||
|
encrypted = run(
|
||||||
|
cmd,
|
||||||
|
cwd=INFRA_DIR,
|
||||||
|
pexpect=True,
|
||||||
|
pexpected={
|
||||||
|
"(?i)new vault password:": vault_password,
|
||||||
|
"(?i)confirm new vault password:": vault_password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
encrypted = (
|
||||||
|
encrypted.strip()
|
||||||
|
.replace("\r", "")
|
||||||
|
.replace("\nEncryption successful", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
chdir(app_dir)
|
||||||
|
log.info("Changed directory back to {}".format(app_dir))
|
||||||
|
|
||||||
|
vault_path = (Path(".") / "deploy.d" / "vault").absolute()
|
||||||
|
if not exists(vault_path):
|
||||||
|
log.info("Creating {}".format(vault_path))
|
||||||
|
mkdir(vault_path)
|
||||||
|
|
||||||
|
var_path = (vault_path / "{}.yml".format(name)).absolute()
|
||||||
|
with open(var_path, "w"):
|
||||||
|
loaded = yaml_load(encrypted, text=True)
|
||||||
|
yaml_dump(var_path, loaded)
|
||||||
|
log.success("Encrypted and saved {} in {}".format(name, var_path))
|
12
autonomic/command/cooppaas.py
Normal file
12
autonomic/command/cooppaas.py
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
"""CoopPaas module."""
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from autonomic.logger import log
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.pass_context
|
||||||
|
def cooppaas(ctx):
|
||||||
|
"""Manage CoopPaas resources."""
|
||||||
|
log.info("Still experimenting...")
|
89
autonomic/command/init.py
Normal file
89
autonomic/command/init.py
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
"""Initialise the toolbelt."""
|
||||||
|
|
||||||
|
|
||||||
|
from os import mkdir
|
||||||
|
from os.path import exists
|
||||||
|
from subprocess import STDOUT
|
||||||
|
|
||||||
|
import click
|
||||||
|
from emoji import emojize
|
||||||
|
|
||||||
|
from autonomic.config import CONFIG_DIR, CONFIG_YAML, INFRA_DIR, INFRA_REPO
|
||||||
|
from autonomic.infra import members
|
||||||
|
from autonomic.logger import log
|
||||||
|
from autonomic.settings import add
|
||||||
|
from autonomic.utils import is_proc, question_ask, run
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.pass_context
|
||||||
|
def init(ctx):
|
||||||
|
"""Initialise the toolbelt."""
|
||||||
|
create_configuration()
|
||||||
|
clone_infrastructure_repo()
|
||||||
|
store_username()
|
||||||
|
install_dependencies()
|
||||||
|
ssh_sanity_check()
|
||||||
|
|
||||||
|
msg = "Hack the planet! :earth_africa:"
|
||||||
|
log.success(emojize(msg, use_aliases=True))
|
||||||
|
|
||||||
|
|
||||||
|
def create_configuration():
|
||||||
|
"""Create toolbelt config directory."""
|
||||||
|
if not exists(CONFIG_DIR):
|
||||||
|
mkdir(CONFIG_DIR)
|
||||||
|
|
||||||
|
if not exists(CONFIG_YAML):
|
||||||
|
with open(CONFIG_YAML, "w") as handle:
|
||||||
|
handle.write("---")
|
||||||
|
|
||||||
|
|
||||||
|
def clone_infrastructure_repo():
|
||||||
|
"""Clone or update the infrastructure repository."""
|
||||||
|
if not exists(INFRA_DIR):
|
||||||
|
cmd = ["git", "clone", INFRA_REPO, INFRA_DIR]
|
||||||
|
run(cmd, stderr=STDOUT)
|
||||||
|
else:
|
||||||
|
cmd = ["git", "pull", "origin", "master"]
|
||||||
|
run(cmd, cwd=INFRA_DIR, stderr=STDOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def store_username():
|
||||||
|
"""Store Autonomic username in the settings."""
|
||||||
|
usernames = members(flatten="username")
|
||||||
|
username = question_ask(
|
||||||
|
"username", "What is you Autonomic username?", usernames,
|
||||||
|
)
|
||||||
|
add({"username": username})
|
||||||
|
|
||||||
|
msg = "Welcome comrade {} :kissing:".format(username)
|
||||||
|
log.success(emojize(msg, use_aliases=True))
|
||||||
|
|
||||||
|
|
||||||
|
def install_dependencies():
|
||||||
|
"""Install infrastructure dependencies."""
|
||||||
|
cmd = ["/usr/bin/python3", "-m", "venv", ".venv"]
|
||||||
|
run(cmd, cwd=INFRA_DIR, stderr=STDOUT)
|
||||||
|
|
||||||
|
cmd = [".venv/bin/pip", "install", "-r", "requirements.txt"]
|
||||||
|
run(cmd, stderr=STDOUT)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
".venv/bin/ansible-galaxy",
|
||||||
|
"install",
|
||||||
|
"-r",
|
||||||
|
"requirements.yml",
|
||||||
|
"--force",
|
||||||
|
]
|
||||||
|
run(cmd, stderr=STDOUT)
|
||||||
|
|
||||||
|
|
||||||
|
def ssh_sanity_check():
|
||||||
|
"""Try to recommend some SSH tips."""
|
||||||
|
if not is_proc("ssh-agent"):
|
||||||
|
msg = "warning: ssh-agent is not running :sob:"
|
||||||
|
log.warning(emojize(msg, use_aliases=True))
|
||||||
|
else:
|
||||||
|
msg = "ssh-agent is running :rocket:"
|
||||||
|
log.success(emojize(msg, use_aliases=True))
|
28
autonomic/config.py
Normal file
28
autonomic/config.py
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
"""Tool configuration."""
|
||||||
|
|
||||||
|
from os.path import expanduser
|
||||||
|
|
||||||
|
HOME_DIR = expanduser("~")
|
||||||
|
|
||||||
|
# configuration directory for the toolbelt
|
||||||
|
CONFIG_DIR = "{}/.autonomic".format(HOME_DIR)
|
||||||
|
|
||||||
|
# SSH url for our infrastructure repository
|
||||||
|
INFRA_REPO = (
|
||||||
|
"ssh://git@git.autonomic.zone:222/autonomic-cooperative/infrastructure.git"
|
||||||
|
)
|
||||||
|
|
||||||
|
# infrastructure directory
|
||||||
|
INFRA_DIR = "{}/infrastructure".format(CONFIG_DIR)
|
||||||
|
|
||||||
|
# list of autonomic members yaml file
|
||||||
|
MEMBERS_YAML = "{}/resources/members.yml".format(INFRA_DIR)
|
||||||
|
|
||||||
|
# directory where we store our ansible action plays
|
||||||
|
ACTIONS_DIR = "{}/actions".format(INFRA_DIR)
|
||||||
|
|
||||||
|
# toolbelt configuration file
|
||||||
|
CONFIG_YAML = "{}/autonomic.yml".format(CONFIG_DIR)
|
||||||
|
|
||||||
|
# password store directory
|
||||||
|
PASS_STORE_DIR = "{}/credentials/password-store".format(INFRA_DIR)
|
43
autonomic/infra.py
Normal file
43
autonomic/infra.py
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
"""Infrastructure module.
|
||||||
|
|
||||||
|
All functionality that requires interacting with the infrastructure repository
|
||||||
|
should live in here. This module relies on the file structure and conventions
|
||||||
|
of the infrastructure repository directory layout. That can change without the
|
||||||
|
code knowing, so the code must act more defensively than normal. This is an
|
||||||
|
experiment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from os import environ
|
||||||
|
|
||||||
|
from autonomic.config import INFRA_DIR, MEMBERS_YAML, PASS_STORE_DIR
|
||||||
|
from autonomic.utils import run, yaml_load
|
||||||
|
|
||||||
|
|
||||||
|
def members(flatten=None):
|
||||||
|
"""The list of Autonomic members."""
|
||||||
|
members = yaml_load(MEMBERS_YAML)
|
||||||
|
|
||||||
|
if members is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if flatten:
|
||||||
|
return [info[flatten] for info in members["autonomic_members"]]
|
||||||
|
|
||||||
|
return members
|
||||||
|
|
||||||
|
|
||||||
|
def run_play(play, env=None):
|
||||||
|
"""Run an Ansible playbook."""
|
||||||
|
cmd = [".venv/bin/ansible-playbook", play]
|
||||||
|
run(cmd, cwd=INFRA_DIR, interactive=True, env=env)
|
||||||
|
|
||||||
|
|
||||||
|
def get_passwd(path):
|
||||||
|
"""Lookup a password from the password store."""
|
||||||
|
env = environ.copy()
|
||||||
|
env.update({"PASSWORD_STORE_DIR": PASS_STORE_DIR})
|
||||||
|
|
||||||
|
cmd = ["pass", "show", path]
|
||||||
|
output = run(cmd, cwd=INFRA_DIR, env=env)
|
||||||
|
|
||||||
|
return output.strip()
|
150
autonomic/logger.py
Normal file
150
autonomic/logger.py
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
"""Logging Module.
|
||||||
|
|
||||||
|
This module is largely borrowed from Ansible Molecule and that is why it is
|
||||||
|
largely uncommented since we didn't write it and luckily, didn't have to! This
|
||||||
|
code just works TM. The same upstream license applies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import colorama
|
||||||
|
|
||||||
|
|
||||||
|
def should_do_markup():
|
||||||
|
return sys.stdout.isatty() and os.environ.get("TERM") != "dumb"
|
||||||
|
|
||||||
|
|
||||||
|
SUCCESS = 100
|
||||||
|
OUT = 101
|
||||||
|
|
||||||
|
|
||||||
|
class LogFilter(object):
|
||||||
|
def __init__(self, level):
|
||||||
|
self.__level = level
|
||||||
|
|
||||||
|
def filter(self, logRecord):
|
||||||
|
return logRecord.levelno <= self.__level
|
||||||
|
|
||||||
|
|
||||||
|
class CustomLogger(logging.getLoggerClass()): # type: ignore
|
||||||
|
def __init__(self, name, level=logging.NOTSET):
|
||||||
|
super(logging.getLoggerClass(), self).__init__(name, level)
|
||||||
|
logging.addLevelName(SUCCESS, "SUCCESS")
|
||||||
|
logging.addLevelName(OUT, "OUT")
|
||||||
|
|
||||||
|
def success(self, msg, *args, **kwargs):
|
||||||
|
if self.isEnabledFor(SUCCESS):
|
||||||
|
self._log(SUCCESS, msg, args, **kwargs)
|
||||||
|
|
||||||
|
def out(self, msg, *args, **kwargs):
|
||||||
|
if self.isEnabledFor(OUT):
|
||||||
|
self._log(OUT, msg, args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class TrailingNewlineFormatter(logging.Formatter):
|
||||||
|
def format(self, record):
|
||||||
|
if record.msg:
|
||||||
|
record.msg = record.msg.rstrip()
|
||||||
|
return super(TrailingNewlineFormatter, self).format(record)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name=None):
|
||||||
|
logging.setLoggerClass(CustomLogger)
|
||||||
|
|
||||||
|
logger = logging.getLogger(name)
|
||||||
|
logger.setLevel(logging.DEBUG)
|
||||||
|
|
||||||
|
logger.addHandler(_get_info_handler())
|
||||||
|
logger.addHandler(_get_out_handler())
|
||||||
|
logger.addHandler(_get_warn_handler())
|
||||||
|
logger.addHandler(_get_error_handler())
|
||||||
|
logger.addHandler(_get_critical_handler())
|
||||||
|
logger.addHandler(_get_success_handler())
|
||||||
|
logger.propagate = False
|
||||||
|
|
||||||
|
return logger
|
||||||
|
|
||||||
|
|
||||||
|
def _get_info_handler():
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setLevel(logging.INFO)
|
||||||
|
handler.addFilter(LogFilter(logging.INFO))
|
||||||
|
handler.setFormatter(
|
||||||
|
TrailingNewlineFormatter("--> {}".format(cyan_text("%(message)s")))
|
||||||
|
)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _get_out_handler():
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setLevel(OUT)
|
||||||
|
handler.addFilter(LogFilter(OUT))
|
||||||
|
handler.setFormatter(TrailingNewlineFormatter(" %(message)s"))
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _get_warn_handler():
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setLevel(logging.WARN)
|
||||||
|
handler.addFilter(LogFilter(logging.WARN))
|
||||||
|
handler.setFormatter(TrailingNewlineFormatter(yellow_text("%(message)s")))
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _get_error_handler():
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setLevel(logging.ERROR)
|
||||||
|
handler.addFilter(LogFilter(logging.ERROR))
|
||||||
|
handler.setFormatter(TrailingNewlineFormatter(red_text("%(message)s")))
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _get_critical_handler():
|
||||||
|
handler = logging.StreamHandler(sys.stderr)
|
||||||
|
handler.setLevel(logging.CRITICAL)
|
||||||
|
handler.addFilter(LogFilter(logging.CRITICAL))
|
||||||
|
handler.setFormatter(
|
||||||
|
TrailingNewlineFormatter(red_text("ERROR: %(message)s"))
|
||||||
|
)
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def _get_success_handler():
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setLevel(SUCCESS)
|
||||||
|
handler.addFilter(LogFilter(SUCCESS))
|
||||||
|
handler.setFormatter(TrailingNewlineFormatter(green_text("%(message)s")))
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
|
||||||
|
def red_text(msg):
|
||||||
|
return color_text(colorama.Fore.RED, msg)
|
||||||
|
|
||||||
|
|
||||||
|
def yellow_text(msg):
|
||||||
|
return color_text(colorama.Fore.YELLOW, msg)
|
||||||
|
|
||||||
|
|
||||||
|
def green_text(msg):
|
||||||
|
return color_text(colorama.Fore.GREEN, msg)
|
||||||
|
|
||||||
|
|
||||||
|
def cyan_text(msg):
|
||||||
|
return color_text(colorama.Fore.CYAN, msg)
|
||||||
|
|
||||||
|
|
||||||
|
def color_text(color, msg):
|
||||||
|
if should_do_markup():
|
||||||
|
return "{}{}{}".format(color, msg, colorama.Style.RESET_ALL)
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
49
autonomic/settings.py
Normal file
49
autonomic/settings.py
Normal file
|
@ -0,0 +1,49 @@
|
||||||
|
"""Toolbelt settings module.
|
||||||
|
|
||||||
|
All code related to working with the ~/.autonomic/settings.yml file. This file
|
||||||
|
is useful for storing information for future lookup when dealing with
|
||||||
|
repetitive cooperative tasks.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from autonomic.config import CONFIG_YAML
|
||||||
|
from autonomic.utils import yaml_dump, yaml_load
|
||||||
|
|
||||||
|
|
||||||
|
def add(yaml: Dict[str, str]):
|
||||||
|
"""Add YAML to the settings file."""
|
||||||
|
from autonomic.command.init import create_configuration
|
||||||
|
|
||||||
|
create_configuration()
|
||||||
|
settings = yaml_load(CONFIG_YAML)
|
||||||
|
|
||||||
|
if settings is None:
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
for key in yaml:
|
||||||
|
settings[key] = yaml[key]
|
||||||
|
|
||||||
|
yaml_dump(CONFIG_YAML, settings)
|
||||||
|
|
||||||
|
|
||||||
|
def remove(key):
|
||||||
|
"""Remove key from the settings file."""
|
||||||
|
settings = yaml_load(CONFIG_YAML)
|
||||||
|
|
||||||
|
if settings is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
settings.pop(key, None)
|
||||||
|
|
||||||
|
yaml_dump(CONFIG_YAML, settings)
|
||||||
|
|
||||||
|
|
||||||
|
def get(key):
|
||||||
|
"""Retrieve settings key."""
|
||||||
|
settings = yaml_load(CONFIG_YAML)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return settings[key]
|
||||||
|
except Exception:
|
||||||
|
return None
|
174
autonomic/utils.py
Normal file
174
autonomic/utils.py
Normal file
|
@ -0,0 +1,174 @@
|
||||||
|
"""Utility functions.
|
||||||
|
|
||||||
|
While mostly a vague concept and sometimes a bad idea, the utilities module is
|
||||||
|
somewhere where reusable system related, data processing and other general
|
||||||
|
functions can live. This codebase seems to have a few of those, so it seems
|
||||||
|
like a necessary module. Let us be critical of our requirements when adding
|
||||||
|
things here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from os import chdir
|
||||||
|
from os.path import exists
|
||||||
|
from pathlib import Path
|
||||||
|
from subprocess import call, check_output
|
||||||
|
from sys import exit as sys_exit
|
||||||
|
|
||||||
|
from pexpect import spawn
|
||||||
|
from psutil import process_iter
|
||||||
|
from PyInquirer import prompt
|
||||||
|
|
||||||
|
from autonomic.config import CONFIG_DIR, CONFIG_YAML, INFRA_DIR
|
||||||
|
from autonomic.logger import log
|
||||||
|
from autonomic.yaml import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def run(
|
||||||
|
cmd, cwd=None, interactive=False, pexpect=False, pexpected=None, **kwargs
|
||||||
|
):
|
||||||
|
"""Run a system command.
|
||||||
|
|
||||||
|
Please note, all **kwargs will be passed into the check_output command so
|
||||||
|
that system call customisation can happen. Please name your keyword
|
||||||
|
arguments (like `cwd`) if you intend that they are used for other logic.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if cwd:
|
||||||
|
chdir(cwd)
|
||||||
|
log.info("Changed directory to {}".format(cwd))
|
||||||
|
|
||||||
|
log.info("Running {}".format(" ".join(cmd)))
|
||||||
|
|
||||||
|
if interactive:
|
||||||
|
return call(cmd, **kwargs)
|
||||||
|
|
||||||
|
if pexpect:
|
||||||
|
child = spawn(" ".join(cmd))
|
||||||
|
for expected, response in pexpected.items():
|
||||||
|
try:
|
||||||
|
child.expect(expected, timeout=5)
|
||||||
|
child.sendline(response)
|
||||||
|
except Exception as exception:
|
||||||
|
msg = "Pexpecting failed, saw {}".format(str(exception))
|
||||||
|
exit(msg)
|
||||||
|
return child.read().decode("utf-8")
|
||||||
|
|
||||||
|
output = check_output(cmd, **kwargs)
|
||||||
|
return output.decode("utf-8")
|
||||||
|
except Exception as exception:
|
||||||
|
msg = "{} failed! Saw {}".format(" ".join(cmd), str(exception))
|
||||||
|
exit(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def exit(msg, code=1):
|
||||||
|
"""Exit and log appropriate level."""
|
||||||
|
if code != 0:
|
||||||
|
log.critical(msg)
|
||||||
|
else:
|
||||||
|
log.info(msg)
|
||||||
|
|
||||||
|
sys_exit(code)
|
||||||
|
|
||||||
|
|
||||||
|
def question_ask(name, message, choices):
|
||||||
|
"""Ask a question."""
|
||||||
|
question = [
|
||||||
|
{
|
||||||
|
"type": "list",
|
||||||
|
"name": name,
|
||||||
|
"message": message,
|
||||||
|
"choices": choices,
|
||||||
|
"filter": lambda answer: answer.lower(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return answer(question, name)
|
||||||
|
|
||||||
|
|
||||||
|
def pass_ask(message):
|
||||||
|
"""Ask for a password."""
|
||||||
|
question = [{"type": "password", "message": message, "name": "password"}]
|
||||||
|
return answer(question, "password")
|
||||||
|
|
||||||
|
|
||||||
|
def input_ask(message):
|
||||||
|
"""Ask for input."""
|
||||||
|
question = [{"type": "input", "message": message, "name": "input"}]
|
||||||
|
return answer(question, "input")
|
||||||
|
|
||||||
|
|
||||||
|
def answer(question, key):
|
||||||
|
"""Retrieve an answer from a prompt"""
|
||||||
|
result = prompt(question)
|
||||||
|
|
||||||
|
if not result:
|
||||||
|
exit("Prompt cancelled")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return result[key]
|
||||||
|
except KeyError:
|
||||||
|
exit("{} key is missing".format(key))
|
||||||
|
|
||||||
|
|
||||||
|
def yaml_load(target, text=False):
|
||||||
|
"""Load a YAML file or text."""
|
||||||
|
if text is True:
|
||||||
|
try:
|
||||||
|
return yaml.load(target)
|
||||||
|
except Exception as exception:
|
||||||
|
log.error(str(exception))
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(target, "r") as handle:
|
||||||
|
return yaml.load(handle.read())
|
||||||
|
except Exception as exception:
|
||||||
|
log.error(str(exception))
|
||||||
|
|
||||||
|
|
||||||
|
def yaml_dump(fpath, data):
|
||||||
|
"""Dump a YAML file."""
|
||||||
|
try:
|
||||||
|
with open(fpath, "w") as handle:
|
||||||
|
yaml.dump(data, handle)
|
||||||
|
except Exception as exception:
|
||||||
|
log.error(str(exception))
|
||||||
|
|
||||||
|
|
||||||
|
def is_proc(name):
|
||||||
|
"""Determine if a process is running or not."""
|
||||||
|
for process in process_iter():
|
||||||
|
try:
|
||||||
|
if name.lower() in process.name().lower():
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def git_status(fpath):
|
||||||
|
"""Check if Git reports changes to be committed."""
|
||||||
|
cmd = ["git", "status", "--porcelain"]
|
||||||
|
output = run(cmd, cwd=fpath)
|
||||||
|
|
||||||
|
if output:
|
||||||
|
msg = "warning: git reports uncommitted changes in {}".format(fpath)
|
||||||
|
log.warning(msg)
|
||||||
|
log.warning(output)
|
||||||
|
else:
|
||||||
|
msg = "No unstaged changes found in {}".format(INFRA_DIR)
|
||||||
|
log.info(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_config_dir():
|
||||||
|
"""Ensure configuration directory is in place."""
|
||||||
|
if not exists(CONFIG_DIR):
|
||||||
|
msg = "{} is missing, did you run 'autonomic init'?".format(CONFIG_YAML)
|
||||||
|
exit(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_deploy_d_dir():
|
||||||
|
"""Ensure deploy.d directory is in place."""
|
||||||
|
deploy_d_dir = (Path(".") / "deploy.d").absolute()
|
||||||
|
|
||||||
|
if not exists(deploy_d_dir):
|
||||||
|
msg = "No deploy.d folder found, are you in the right place?"
|
||||||
|
exit(msg)
|
13
autonomic/yaml.py
Normal file
13
autonomic/yaml.py
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
"""YAML initialisation module.
|
||||||
|
|
||||||
|
Since we would like a consistent approach to how we load, manipulate and dump
|
||||||
|
YAML, we centralise the approach into a single module to avoid duplicating all
|
||||||
|
the customisation code across the code base.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
yaml = YAML()
|
||||||
|
|
||||||
|
# ensure that "---" is always at the top of the YAML file
|
||||||
|
yaml.explicit_start = True # type: ignore
|
|
@ -1,8 +1,8 @@
|
||||||
author = 'decentral1se'
|
author = "decentral1se"
|
||||||
copyright = '2019, decentral1se'
|
copyright = "2019, decentral1se"
|
||||||
html_static_path = ['_static']
|
html_static_path = ["_static"]
|
||||||
html_theme = 'sphinx_rtd_theme'
|
html_theme = "sphinx_rtd_theme"
|
||||||
master_doc = 'index'
|
master_doc = "index"
|
||||||
project = 'autonomic'
|
project = "autonomic utility belt"
|
||||||
templates_path = ['_templates']
|
templates_path = ["_templates"]
|
||||||
extensions = ['sphinx.ext.autodoc', 'sphinx_autodoc_typehints']
|
extensions = ["sphinx.ext.autodoc", "sphinx_autodoc_typehints"]
|
||||||
|
|
|
@ -20,11 +20,9 @@ black = "^19.10b0"
|
||||||
isort = "^4.3.21"
|
isort = "^4.3.21"
|
||||||
flake8 = "^3.7.9"
|
flake8 = "^3.7.9"
|
||||||
|
|
||||||
|
|
||||||
[tool.black]
|
[tool.black]
|
||||||
line-length = 80
|
line-length = 80
|
||||||
target-version = ["py38"]
|
target-version = ["py38"]
|
||||||
skip-string-normalization = true
|
|
||||||
include = '\.pyi?$'
|
include = '\.pyi?$'
|
||||||
|
|
||||||
[tool.towncrier]
|
[tool.towncrier]
|
||||||
|
|
10
setup.cfg
10
setup.cfg
|
@ -6,7 +6,7 @@ max-line-length = 80
|
||||||
|
|
||||||
[isort]
|
[isort]
|
||||||
known_first_party = autonomic
|
known_first_party = autonomic
|
||||||
known_third_party = pytest
|
known_third_party = pytest, psutil
|
||||||
line_length = 80
|
line_length = 80
|
||||||
multi_line_output = 3
|
multi_line_output = 3
|
||||||
skip = .venv, .tox
|
skip = .venv, .tox
|
||||||
|
@ -43,13 +43,19 @@ packages = find:
|
||||||
zip_safe = False
|
zip_safe = False
|
||||||
install_requires =
|
install_requires =
|
||||||
click >= 7.1.1, <= 8.0
|
click >= 7.1.1, <= 8.0
|
||||||
|
colorama >= 0.4.3, <= 0.5
|
||||||
|
emoji >= 0.5.4, <= 0.6
|
||||||
|
pexpect >= 4.8.0, <= 4.9
|
||||||
|
psutil >= 5.7.0, <= 6.0
|
||||||
|
pyinquirer >= 1.0.3, <= 1.1
|
||||||
|
ruamel.yaml >= 0.16.10, <= 0.17
|
||||||
|
|
||||||
[options.packages.find]
|
[options.packages.find]
|
||||||
where = .
|
where = .
|
||||||
|
|
||||||
[options.entry_points]
|
[options.entry_points]
|
||||||
console_scripts =
|
console_scripts =
|
||||||
autonomic = autonomic.__main__:main
|
autonomic = autonomic.__main__:autonomic
|
||||||
|
|
||||||
[build_sphinx]
|
[build_sphinx]
|
||||||
all_files = 1
|
all_files = 1
|
||||||
|
|
20
test/test_settings.py
Normal file
20
test/test_settings.py
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from autonomic.settings import add, get, remove
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def config_yml():
|
||||||
|
from autonomic.command.init import create_configuration
|
||||||
|
|
||||||
|
create_configuration()
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_get_remove(config_yml):
|
||||||
|
add({"foo": "bar"})
|
||||||
|
|
||||||
|
assert get("doesnt-exist") is None
|
||||||
|
assert get("foo") == "bar"
|
||||||
|
|
||||||
|
remove("foo")
|
||||||
|
assert get("foo") is None
|
44
test/test_utils.py
Normal file
44
test/test_utils.py
Normal file
|
@ -0,0 +1,44 @@
|
||||||
|
from subprocess import STDOUT
|
||||||
|
|
||||||
|
from autonomic.utils import is_proc, run, yaml_dump, yaml_load
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_kwargs():
|
||||||
|
assert run(["whoami"], stderr=STDOUT) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_cwd(tmp_path):
|
||||||
|
directory = tmp_path / "test"
|
||||||
|
directory.mkdir()
|
||||||
|
|
||||||
|
testfile = directory / "testfile.txt"
|
||||||
|
testfile.write_text("hello, world")
|
||||||
|
|
||||||
|
output = run(["ls"], cwd=directory.absolute())
|
||||||
|
assert "testfile.txt" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_load(tmp_path):
|
||||||
|
directory = tmp_path / "test"
|
||||||
|
directory.mkdir()
|
||||||
|
|
||||||
|
testfile = directory / "testfile.yml"
|
||||||
|
testfile.write_text("---\nusername: foobar")
|
||||||
|
|
||||||
|
loaded = yaml_load(testfile.absolute())
|
||||||
|
assert loaded["username"] == "foobar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_dump(tmp_path):
|
||||||
|
directory = tmp_path / "test"
|
||||||
|
directory.mkdir()
|
||||||
|
|
||||||
|
testfile = directory / "testfile.yml"
|
||||||
|
yaml_dump(testfile.absolute(), {"username": "foobar"})
|
||||||
|
|
||||||
|
loaded = yaml_load(testfile.absolute())
|
||||||
|
assert loaded["username"] == "foobar"
|
||||||
|
|
||||||
|
|
||||||
|
def test_is_proc():
|
||||||
|
assert is_proc("pytest") is True
|
|
@ -2,9 +2,9 @@
|
||||||
|
|
||||||
|
|
||||||
def test_version_fails_gracefully(mocker):
|
def test_version_fails_gracefully(mocker):
|
||||||
target = 'pkg_resources.get_distribution'
|
target = "pkg_resources.get_distribution"
|
||||||
mocker.patch(target, side_effect=Exception())
|
mocker.patch(target, side_effect=Exception())
|
||||||
|
|
||||||
from autonomic.__init__ import __version__
|
from autonomic.__init__ import __version__
|
||||||
|
|
||||||
assert __version__ == 'unknown'
|
assert __version__ == "unknown"
|
||||||
|
|
5
test/test_yaml.py
Normal file
5
test/test_yaml.py
Normal file
|
@ -0,0 +1,5 @@
|
||||||
|
from autonomic.yaml import yaml
|
||||||
|
|
||||||
|
|
||||||
|
def test_yaml_config():
|
||||||
|
assert yaml.explicit_start is True
|
2
tox.ini
2
tox.ini
|
@ -6,7 +6,6 @@ envlist =
|
||||||
format
|
format
|
||||||
type
|
type
|
||||||
docs
|
docs
|
||||||
changelog
|
|
||||||
metadata-release
|
metadata-release
|
||||||
skip_missing_interpreters = True
|
skip_missing_interpreters = True
|
||||||
isolated_build = True
|
isolated_build = True
|
||||||
|
@ -50,6 +49,7 @@ description = build the documentation
|
||||||
skipdist = True
|
skipdist = True
|
||||||
deps =
|
deps =
|
||||||
sphinx
|
sphinx
|
||||||
|
sphinx-autodoc-typehints
|
||||||
sphinx_rtd_theme
|
sphinx_rtd_theme
|
||||||
commands = python -m setup build_sphinx
|
commands = python -m setup build_sphinx
|
||||||
|
|
||||||
|
|
Reference in New Issue
Block a user