#!/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()