This commit is contained in:
507
pyabra.py
Executable file
507
pyabra.py
Executable file
@ -0,0 +1,507 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user