508 lines
15 KiB
Python
Executable File
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()
|