This repository has been archived on 2021-07-22. You can view files and clone it, but cannot push or open issues or pull requests.
pyabra/pyabra.py

508 lines
15 KiB
Python
Executable File

#!/usr/bin/env python3
from os import chdir, environ, getcwd, mkdir
from os.path import exists, expanduser
from pathlib import Path
from shlex import split
from shutil import rmtree, which
from subprocess import run
from sys import exit
from typing import Dict, List
from click import (
argument,
command,
group,
make_pass_decorator,
option,
pass_context,
prompt,
secho,
version_option,
)
from jsonschema import SchemaError, ValidationError, validate
from ruamel.yaml import YAML
from tabulate import tabulate
APPS = ["wordpress"]
APPS_MAP = {"wordpress": "https://git.autonomic.zone/compose-stacks/wordpress"}
DEFAULT_CONTEXT = {
"context": "local",
"contexts": [{"name": "local", "endpoint": "unix:///var/run/docker.sock"}],
}
HOME_PATH = expanduser("~")
SCHEMA_PATH = Path(getcwd()) / "abra" / "schemas"
CONFIG_PATH = Path(f"{HOME_PATH}/.abra")
CLONE_PATH = Path(f"{CONFIG_PATH}/clones")
CONFIG_YAML_PATH = Path(f"{CONFIG_PATH}/config.yml")
class Application:
"""An application interface."""
def __init__(self, name, mgmr):
self._name = name
self._mgmr = mgmr
self._path = CONFIG_PATH / self._mgmr.context / self._name
if not exists(self._path):
mkdir(self._path)
self._clone(name)
@property
def package_schema(self):
return {
"type": "object",
"required": ["name", "description"],
"properties": {
"name": {"type": "string"},
"description": {"type": "string"},
"arguments": {
"anyOf": [
{
"type": "object",
"patternProperties": {
".": {
"anyOf": [
{
"type": "object",
"required": ["description"],
"properties": {
"description": {
"type": "string"
},
"example": {"type": "string"},
},
},
{"type": "null"},
]
}
},
},
{"type": "null"},
]
},
"secrets": {
"type": "object",
"patternProperties": {
".": {
"anyOf": [
{
"type": "object",
"required": ["description"],
"properties": {
"description": {"type": "string"},
"length": {"type": "number"},
},
},
{"type": "null"},
]
}
},
},
},
}
@property
def package(self):
package = self._load_package(self._name)
self._validate(package)
return package
@property
def secrets(self):
return self.package.get("secrets", [])
@property
def arguments(self):
return self.package.get("arguments", [])
@property
def stored_secrets(self):
path = CONFIG_PATH / self._mgmr.context / self._name / "secrets.yml"
try:
with open(path, "r") as handle:
return self._mgmr._yaml.load(handle.read())
except Exception as exception:
self._mgmr.fail(f"Reading {path} failed: {exception}")
@property
def stored_arguments(self):
path = CONFIG_PATH / self._mgmr.context / self._name / "arguments.yml"
try:
with open(path, "r") as handle:
return self._mgmr._yaml.load(handle.read())
except Exception as exception:
self._mgmr.fail(f"Reading {path} failed: {exception}")
def _clone(self, name):
path = CLONE_PATH / name
url = APPS_MAP[name]
if exists(path):
return
try:
command = split(f"git clone {url} {path}")
run(command, capture_output=True)
except Exception as exception:
self._mgmr.fail(f"Cloning {url} failed: {exception}")
def _load_package(self, name):
path = CLONE_PATH / name / "package.yml"
try:
with open(path, "r") as handle:
return self._mgmr._yaml.load(handle.read())
except Exception as exception:
self._mgmr.fail(f"Reading {path} failed: {exception}")
def _validate(self, config):
try:
validate(config, self.package_schema)
except ValidationError as exception:
self._mgmr.fail(f"{CONFIG_YAML_PATH}: {exception.message}")
except SchemaError as exception:
self._mgmr.fail(f"SCHEMA PANIC: {str(exception.message)}")
def ask_inputs(self):
self.ask_arguments()
self.ask_secrets()
def ask_arguments(self):
answers = {}
for argument in self.arguments:
description = self.arguments[argument]["description"]
answers[argument] = prompt(description)
self._mgmr._yaml.dump(answers, self._path / "arguments.yml")
def ask_secrets(self):
answers = {}
for secret in self.secrets:
description = self.secrets[secret]["description"]
answers[secret] = prompt(description, hide_input=True)
self._mgmr._yaml.dump(answers, self._path / "secrets.yml")
def tprint(self):
output = {}
output.update(self.stored_arguments)
output.update(self.stored_secrets)
self._mgmr.tprint(output.items(), ["input", "value"])
def deploy(self):
# TODO(decentral1se): manage secret creation here too
# TODO(decentral1se): switch over to an easier test app (not wp)
# TODO(decentral1se): skip arg/secret asking if stored (--ask-again)
# TODO(decentral1se): add a confirm/deploy check
# TODO(decentral1se): if domain provided, wait for it to come up
cwd = getcwd()
path = CONFIG_PATH / "clones" / self._name
try:
chdir(path)
env = environ.copy()
env.update({"DOCKER_HOST": self._mgmr.endpoint})
for name in self.arguments:
env[name.upper()] = self.stored_arguments[name]
for name in self.secrets:
env[f"{name.upper()}_VERSION"] = f"{name}_v1"
command = split(f"docker stack deploy -c compose.yml {self._name}")
run(command, env=env)
except Exception as exception:
self._mgmr.fail(f"Deployment failed: {exception}")
finally:
chdir(cwd)
class Config:
"""The core configuration."""
def __init__(self, mgmr):
self._mgmr = mgmr
self._create_context_dirs()
@property
def config(self):
config = self._load_config()
self._validate(config)
return config
@property
def config_schema(self):
return {
"type": "object",
"required": ["context", "contexts"],
"properties": {
"context": {"type": "string"},
"contexts": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "endpoint"],
"properties": {
"name": {"type": "string"},
"endpoint": {"type": "string"},
},
},
},
},
}
@property
def context(self):
return self.config["context"]
@property
def contexts(self):
return {
context["name"]: context["endpoint"]
for context in self.config["contexts"]
}
@property
def endpoint(self):
return self.contexts[self.context]
def _validate(self, config):
try:
validate(config, self.config_schema)
except ValidationError as exception:
self._mgmr.fail(f"{CONFIG_YAML_PATH}: {exception.message}")
except SchemaError as exception:
self._mgmr.fail(f"SCHEMA PANIC: {str(exception.message)}")
def _create_context_dirs(self):
for context in self.contexts:
path = Path(CONFIG_PATH / context).absolute()
if not exists(path):
mkdir(path)
def add_context(self, name: str, endpoint: str):
contexts = self.config["contexts"]
contexts.append({"name": name, "endpoint": endpoint})
config = self.config
config["contexts"] = contexts
self._save(config)
self._create_context_dirs()
def remove_context(self, name: str):
contexts = self.config["contexts"]
filtered = filter(lambda c: c["name"] != name, contexts)
config = self.config
config["contexts"] = list(filtered)
self._save(config)
try:
rmtree(CONFIG_PATH / name)
except Exception:
pass
def use_context(self, name: str):
config = self.config
if name not in self.contexts:
self._mgmr.fail(f"Unknown context '{name}'")
config["context"] = name
self._save(config)
def _load_config(self):
try:
with open(CONFIG_YAML_PATH, "r") as handle:
return self._mgmr._yaml.load(handle.read())
except Exception as exception:
self._mgmr.fail(f"Reading {CONFIG_YAML_PATH} failed: {exception}")
def _save(self, data):
return self._mgmr._yaml.dump(data, CONFIG_YAML_PATH)
class ConfigManager:
"""One-stop-shop for programming utilities handed to each click command.
The ConfigManager is passed to each click command by using the `pass_mgmr`
decorator. This allows each click command to have access to this object
automatically and therefore have access to a common API surface which makes
manipulating configs/apps/packages/related filesystem easier and more
predictable.
"""
def __init__(self) -> None:
self._yaml = YAML()
self._yaml.explicit_start = True # type: ignore
if not exists(CONFIG_PATH):
mkdir(CONFIG_PATH)
if not exists(CLONE_PATH):
mkdir(CLONE_PATH)
if not exists(CONFIG_YAML_PATH):
self._yaml.dump(DEFAULT_CONTEXT, CONFIG_YAML_PATH)
self._config = Config(mgmr=self)
@property
def config(self):
return self._config
@property
def context(self) -> str:
return self._config.context
@property
def endpoint(self) -> str:
return self._config.endpoint
@property
def contexts(self) -> Dict:
return self._config.contexts
def tprint(self, table: List, headers: List):
secho(tabulate(table, headers), fg="green", bold=True)
def fail(self, msg: str):
secho(msg, fg="red", bold=True)
exit(1)
def success(self, msg: str):
secho(msg, fg="green", bold=True)
exit(1)
def get_app(self, name: str) -> Application:
return Application(name, self)
pass_mgmr = make_pass_decorator(ConfigManager, ensure=True)
@group()
@version_option()
def main():
"""
\b
_____ _____ _ _
/ __ \ / __ \ | | |
| / \/ ___ ___ _ __ | / \/ | ___ _ _ __| |
| | / _ \ / _ \| '_ \ | | | |/ _ \| | | |/ _` |
| \__/\ (_) | (_) | |_) | | \__/\ | (_) | |_| | (_| |
\____/\___/ \___/| .__/ \____/_|\___/ \__,_|\__,_|
| |
|_|
Hack the planet!
""" # noqa
@group()
def context():
"""Manage deployment contexts.
A deployment context is a remote environment like a virtual private server
(VPS) provided by a hosting provider which is running program which can
understand and deploy cooperative cloud applications. You can store
multiple contexts on your local machine and switch to them as needed when
you need to run deployments.
"""
@context.command()
@pass_mgmr
def ls(mgmr):
"""List existing contexts."""
table = [
[name, endpoint, "yes" if name == mgmr.context else "no"]
for name, endpoint in mgmr.contexts.items()
]
mgmr.tprint(table, ["name", "endpoint", "selected"])
@context.command()
@argument("name")
@pass_mgmr
def rm(mgmr, name: str):
"""Remove a context."""
if name not in mgmr.contexts:
mgmr.success(f"Context '{name}' already removed")
if name == mgmr.context:
mgmr.fail(f"Cannot remove curent context '{name}'")
mgmr.config.remove_context(name)
mgmr.success(f"The context '{name}' is now removed")
@context.command()
@argument("name")
@pass_mgmr
def use(mgmr, name: str):
"""Use a context."""
if mgmr.context == name:
mgmr.success(f"Already using context '{name}'")
if name not in mgmr.contexts:
mgmr.fail(f"Unknown context '{name}'")
mgmr.config.use_context(name)
mgmr.success(f"Now using context '{name}'")
@context.command()
@argument("name")
@argument("endpoint")
@option(
"-u",
"--auto-use",
is_flag=True,
default=False,
help="Use context directly after adding it",
)
@pass_mgmr
@pass_context
def add(context, mgmr, name: str, endpoint: str, auto_use: bool):
"""Add a new context."""
if name in mgmr.contexts:
mgmr.fail(f"The context '{name}' is already stored")
mgmr.config.add_context(name, endpoint)
mgmr.success(f"The context '{name}' is now stored")
if auto_use:
context.click_context.invoke(use, context=name)
@command()
@argument("name")
@pass_mgmr
def deploy(mgmr, name: str) -> None:
"""Deploy an application."""
try:
app = mgmr.get_app(name)
except KeyError:
mgmr.fail(f"Unknown application '{name}'")
app.ask_inputs()
app.tprint()
app.deploy()
@command()
@pass_mgmr
def doctor(mgmr):
"""Check local machine for problems.
The abra command-line tool requires a number of packages to be correctly
installed and configured on your local machine. In order to help you make
sure you have everything setup properly, we provide a small diagnostic
doctor facility.
"""
table = [
["Docker", "OK" if which("docker") else "Missing"],
["Git", "OK" if which("git") else "Missing"],
]
mgmr.tprint(table, ["Check", "Status", "Documentation"])
main.add_command(context)
main.add_command(deploy)
main.add_command(doctor)
if __name__ == "__main__":
main()