diff --git a/.arclint b/.arclint --- a/.arclint +++ b/.arclint @@ -47,6 +47,7 @@ ], "include": [ "(\\.py$)", + "(^roles/saas-rabbitmq/server/content.sls$)", "(^roles/viperserv/eggdrop/cron.sls$)", "(^roles/webserver-legacy/php-builder/source.sls$)", "(^roles/webserver-legacy/php-sites/cleanup.sls$)" diff --git a/_modules/rabbitmq.py b/_modules/rabbitmq.py deleted file mode 100644 --- a/_modules/rabbitmq.py +++ /dev/null @@ -1,36 +0,0 @@ -# -*- coding: utf-8 -*- - -# ------------------------------------------------------------- -# Salt — RabbitMQ execution module -# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -# Project: Nasqueron -# Description: Allow to use RabbitMQ management plugin HTTP API -# License: BSD-2-Clause -# ------------------------------------------------------------- - - -import base64 -import hashlib -import secrets - - -# ------------------------------------------------------------- -# Credentials -# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -def compute_password_hash(password): - salt = secrets.randbits(32) - return _compute_password_hash_with_salt(salt, password) - - -def _compute_password_hash_with_salt(salt, password): - """Reference: https://rabbitmq.com/passwords.html#computing-password-hash""" - salt = salt.to_bytes(4, "big") # salt is a 32 bits (4 bytes) value - - m = hashlib.sha256() - m.update(salt) - m.update(password.encode("utf-8")) - result = salt + m.digest() - - return base64.b64encode(result).decode("utf-8") diff --git a/_modules/rabbitmq_api.py b/_modules/rabbitmq_api.py new file mode 100644 --- /dev/null +++ b/_modules/rabbitmq_api.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 + +# ------------------------------------------------------------- +# Salt - RabbitMQ management HTTP API client +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Project: Nasqueron +# Description: Connect to RabbitMQ management HTTP API +# License: BSD-2-Clause +# ------------------------------------------------------------- + + +import base64 +import hashlib +import json +import logging +import secrets + +import requests +from requests.auth import HTTPBasicAuth + + +log = logging.getLogger(__name__) + + +HTTP_SUCCESS_CODES = [200, 201, 204] +HTTP_CONTENT_CODES = [200] + + +# ------------------------------------------------------------- +# RabbitMQ management HTTP API client +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def _request(cluster, method, path, data=None): + args = __opts__["rabbitmq"][cluster] + + url = args["url"] + "/" + path + + if args["auth"] == "basic": + auth = HTTPBasicAuth(args["user"], args["password"]) + else: + raise RuntimeError( + f"RabbitMQ HTTP API authentication scheme not supported: {args['auth']}" + ) + + headers = { + "User-agent": "Salt-RabbitMQ/1.0", + } + + if data is not None: + data = json.dumps(data) + + log.debug(f"HTTP request {method} to {url}") + log.trace(f"Payload: {data}") + r = requests.request(method, url, headers=headers, auth=auth, data=data) + + if r.status_code not in HTTP_SUCCESS_CODES: + log.error(f"HTTP status code {r.status_code}, 2xx expected.") + raise RuntimeError(f"Status code is {r.status_code}") + + if r.status_code not in HTTP_CONTENT_CODES: + log.trace( + f"HTTP response is {r.status_code}. The API doesn't include any content for this code." + ) + return True + + return r.json() + + +# ------------------------------------------------------------- +# Execution module methods +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +ARGS_USER = ["password_hash", "tags"] +ARGS_VHOST = ["description", "tags", "tracing"] +ARGS_EXCHANGE = ["type", "auto_delete", "durable", "internal", "arguments"] +ARGS_QUEUE = ["auto_delete", "durable", "arguments", "node"] +ARGS_BINDING = ["routing_key", "arguments"] + + +def overview(cluster): + return _request(cluster, "GET", "overview") + + +def list_users(cluster): + return _request(cluster, "GET", "users") + + +def get_user(cluster, user): + user = requests.utils.quote(user, safe="") + return _request(cluster, "GET", f"users/{user}") + + +def update_user(cluster, user, **kwargs): + user = requests.utils.quote(user, safe="") + data = {} + for arg in ARGS_USER: + if arg in kwargs: + data[arg] = kwargs[arg] + + if "password" in kwargs: + if "password_hash" in kwargs: + raise RuntimeError( + "You can't specify both password and password_hash option." + ) + data["password_hash"] = compute_password_hash(kwargs["password"]) + + return _request(cluster, "PUT", f"users/{user}", data) + + +def delete_user(cluster, user): + user = requests.utils.quote(user, safe="") + return _request(cluster, "DELETE", f"users/{user}") + + +def user_exists(cluster, user): + return user in [result["name"] for result in list_users(cluster)] + + +def list_vhosts(cluster): + return _request(cluster, "GET", "vhosts") + + +def get_vhost(cluster, vhost): + vhost = requests.utils.quote(vhost, safe="") + return _request(cluster, "GET", f"vhosts/{vhost}") + + +def update_vhost(cluster, vhost, **kwargs): + vhost = requests.utils.quote(vhost, safe="") + data = {} + for arg in ARGS_VHOST: + if arg in kwargs: + data[arg] = kwargs[arg] + + return _request(cluster, "PUT", f"vhosts/{vhost}", data) + + +def delete_vhost(cluster, vhost): + vhost = requests.utils.quote(vhost, safe="") + return _request(cluster, "DELETE", f"vhosts/{vhost}") + + +def vhost_exists(cluster, vhost): + return vhost in [result["name"] for result in list_vhosts(cluster)] + + +def list_exchanges(cluster, vhost): + vhost = requests.utils.quote(vhost, safe="") + return _request(cluster, "GET", f"exchanges/{vhost}") + + +def get_exchange(cluster, vhost, exchange): + vhost = requests.utils.quote(vhost, safe="") + exchange = requests.utils.quote(exchange, safe="") + return _request(cluster, "GET", f"exchanges/{vhost}/{exchange}") + + +def update_exchange(cluster, vhost, exchange, **kwargs): + vhost = requests.utils.quote(vhost, safe="") + exchange = requests.utils.quote(exchange, safe="") + data = {} + for arg in ARGS_EXCHANGE: + if arg in kwargs: + data[arg] = kwargs[arg] + + return _request(cluster, "PUT", f"exchanges/{vhost}/{exchange}", data) + + +def delete_exchange(cluster, vhost, exchange): + vhost = requests.utils.quote(vhost, safe="") + exchange = requests.utils.quote(exchange, safe="") + return _request(cluster, "DELETE", f"exchanges/{vhost}/{exchange}") + + +def exchange_exists(cluster, vhost, exchange): + vhost = requests.utils.quote(vhost, safe="") + return exchange in [result["name"] for result in list_exchanges(cluster, vhost)] + + +def list_queues(cluster, vhost): + vhost = requests.utils.quote(vhost, safe="") + return _request(cluster, "GET", f"queues/{vhost}") + + +def get_queue(cluster, vhost, queue): + vhost = requests.utils.quote(vhost, safe="") + queue = requests.utils.quote(queue, safe="") + return _request(cluster, "GET", f"queues/{vhost}/{queue}") + + +def update_queue(cluster, vhost, queue, **kwargs): + vhost = requests.utils.quote(vhost, safe="") + queue = requests.utils.quote(queue, safe="") + + data = {} + for arg in ARGS_QUEUE: + if arg in kwargs: + data[arg] = kwargs[arg] + + return _request(cluster, "PUT", f"queues/{vhost}/{queue}", data) + + +def delete_queue(cluster, vhost, queue): + vhost = requests.utils.quote(vhost, safe="") + queue = requests.utils.quote(queue, safe="") + return _request(cluster, "DELETE", f"queues/{vhost}/{queue}") + + +def queue_exists(cluster, vhost, queue): + vhost = requests.utils.quote(vhost, safe="") + return queue in [result["name"] for result in list_queues(cluster, vhost)] + + +def list_bindings(cluster, vhost): + vhost = requests.utils.quote(vhost, safe="") + return _request(cluster, "GET", f"bindings/{vhost}") + + +def queue_bind(cluster, vhost, exchange, queue, *kwargs): + vhost = requests.utils.quote(vhost, safe="") + queue = requests.utils.quote(queue, safe="") + exchange = requests.utils.quote(exchange, safe="") + + data = {} + for arg in ARGS_BINDING: + if arg in kwargs: + data[arg] = kwargs[arg] + + return _request(cluster, "POST", f"bindings/{vhost}/e/{exchange}/q/{queue}", data) + + +def exchange_bind(cluster, vhost, source, destination, *kwargs): + vhost = requests.utils.quote(vhost, safe="") + source = requests.utils.quote(source, safe="") + destination = requests.utils.quote(destination, safe="") + + data = {} + for arg in ARGS_BINDING: + if arg in kwargs: + data[arg] = kwargs[arg] + + return _request( + cluster, "POST", f"bindings/{vhost}/e/{source}/e/{destination}", data + ) + + +def update_permissions(cluster, vhost, user, **kwargs): + data = { + "configure": "^$", + "read": "^$", + "write": "^$", + } + + for arg in data: + if arg in kwargs: + data[arg] = kwargs[arg] + + return _request(cluster, "PUT", f"/api/permissions/{vhost}/{user}", data) + + +# ------------------------------------------------------------- +# Credentials helper methods +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def compute_password_hash(password): + salt = secrets.randbits(32) + return compute_password_hash_with_salt(salt, password) + + +def compute_password_hash_with_salt(salt, password): + """Reference: https://rabbitmq.com/passwords.html#computing-password-hash""" + salt = salt.to_bytes(4, "big") # salt is a 32 bits (4 bytes) value + + m = hashlib.sha256() + m.update(salt) + m.update(password.encode("utf-8")) + result = salt + m.digest() + + return base64.b64encode(result).decode("utf-8") diff --git a/_states/rabbitmq.py b/_states/rabbitmq.py new file mode 100644 --- /dev/null +++ b/_states/rabbitmq.py @@ -0,0 +1,342 @@ +#!/usr/bin/env python3 + +# ------------------------------------------------------------- +# Salt - RabbitMQ management HTTP API state module +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Project: Nasqueron +# Description: Configure RabbitMQ through management HTTP API +# License: BSD-2-Clause +# ------------------------------------------------------------- + + +import logging + + +log = logging.getLogger(__name__) + + +# ------------------------------------------------------------- +# User +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def user_present(name, cluster, credential, tags=[]): + password_hash = _get_password_hash(credential) + return _user_present(name, cluster, password_hash=password_hash, tags=tags) + + +def _user_present(name, cluster, password_hash=None, tags=[]): + ret = {"name": name, "result": False, "changes": {}, "comment": ""} + + expected = { + "password_hash": password_hash, + "tags": tags, + } + actual = {} + is_existing = False + + if __salt__["rabbitmq_api.user_exists"](cluster, name): + user = __salt__["rabbitmq_api.get_user"](cluster, name) + is_existing = True + actual = { + "password_hash": user["password_hash"], + "tags": user["tags"], + } + + if actual == expected: + ret["result"] = True + ret["comment"] = f"User {name} is up to date" + return ret + + ret["changes"] = _changes(actual, expected) + update_verb = "updated" if is_existing else "created" + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"User {name} will be {update_verb}" + return ret + + try: + __salt__["rabbitmq_api.update_user"](cluster, name, **expected) + except Exception as e: + e = str(e) + log.error("Can't update RabbitMQ user: " + e) + ret["comment"] = e + return ret + + ret["result"] = True + ret["comment"] = f"User {update_verb}" + + return ret + + +def user_absent(name, cluster): + ret = {"name": name, "result": False, "changes": {}, "comment": ""} + + if not __salt__["rabbitmq_api.user_exists"](cluster, name): + ret["result"] = True + ret["comment"] = f"User {name} is absent" + return ret + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"User {name} will be deleted" + return ret + + try: + __salt__["rabbitmq_api.delete_user"](cluster, name) + except Exception as e: + e = str(e) + log.error("Can't delete RabbitMQ user: " + e) + ret["comment"] = e + return ret + + ret["result"] = True + ret["comment"] = "User deleted" + + return ret + + +# ------------------------------------------------------------- +# Vhost +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def vhost_present(name, cluster, description="", tags=[], tracing=False): + ret = {"name": name, "result": False, "changes": {}, "comment": ""} + + expected = { + "description": description, + "tags": tags, + "tracing": tracing, + } + actual = {} + is_existing = False + + if __salt__["rabbitmq_api.vhost_exists"](cluster, name): + vhost = __salt__["rabbitmq_api.get_vhost"](cluster, name) + is_existing = True + actual = { + "description": vhost["metadata"]["description"], + "tags": vhost["metadata"]["tags"], + "tracing": vhost["tracing"], + } + + if actual == expected: + ret["result"] = True + ret["comment"] = f"Vhost {name} is up to date" + return ret + + ret["changes"] = _changes(actual, expected) + update_verb = "updated" if is_existing else "created" + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Vhost {name} will be {update_verb}" + return ret + + try: + __salt__["rabbitmq_api.update_vhost"](cluster, name, **expected) + except Exception as e: + e = str(e) + log.error("Can't update RabbitMQ vhost: " + e) + ret["comment"] = e + return ret + + ret["result"] = True + ret["comment"] = f"Vhost {update_verb}" + + return ret + + +def vhost_absent(name, cluster): + ret = {"name": name, "result": False, "changes": {}, "comment": ""} + + if not __salt__["rabbitmq_api.vhost_exists"](cluster, name): + ret["result"] = True + ret["comment"] = f"Vhost {name} is absent" + return ret + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Vhost {name} will be deleted" + return ret + + try: + __salt__["rabbitmq_api.delete_vhost"](cluster, name) + except Exception as e: + e = str(e) + log.error("Can't delete RabbitMQ vhost: " + e) + ret["comment"] = e + return ret + + ret["result"] = True + ret["comment"] = "Vhost deleted" + + return ret + + +# ------------------------------------------------------------- +# Exchange +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def exchange_present( + name, + cluster, + vhost, + type, + auto_delete=False, + durable=False, + internal=False, + arguments={}, +): + ret = {"name": name, "result": False, "changes": {}, "comment": ""} + + expected = { + "type": type, + "auto_delete": auto_delete, + "durable": durable, + "internal": internal, + "arguments": arguments, + } + actual = {} + is_existing = False + + if __salt__["rabbitmq_api.exchange_exists"](cluster, vhost, name): + exchange = __salt__["rabbitmq_api.get_exchange"](cluster, vhost, name) + is_existing = True + actual = { + "type": exchange["type"], + "auto_delete": exchange["auto_delete"], + "durable": exchange["durable"], + "internal": exchange["internal"], + "arguments": exchange["arguments"], + } + + if actual == expected: + ret["result"] = True + ret["comment"] = f"Exchange {name} is up to date" + return ret + + ret["changes"] = _changes(actual, expected) + update_verb = "deleted then created back" if is_existing else "created" + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"Exchange {name} will be {update_verb}" + return ret + + try: + if is_existing: + operation = "delete" + __salt__["rabbitmq_api.delete_exchange"](cluster, vhost, name) + + operation = "create" + __salt__["rabbitmq_api.update_exchange"](cluster, vhost, name, **expected) + except Exception as e: + e = str(e) + log.error(f"Can't {operation} RabbitMQ exchange: {e}") + ret["comment"] = e + return ret + + ret["result"] = True + ret["comment"] = f"Exchange {update_verb}" + + return ret + + +# ------------------------------------------------------------- +# Queue +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def queue_present( + name, cluster, vhost, auto_delete=False, durable=False, arguments={}, node=None +): + ret = {"name": name, "result": False, "changes": {}, "comment": ""} + + expected = { + "auto_delete": auto_delete, + "durable": durable, + "arguments": arguments, + } + if node is not None: + expected["node"] = node + + actual = {} + is_existing = False + + if __salt__["rabbitmq_api.queue_exists"](cluster, vhost, name): + queue = __salt__["rabbitmq_api.get_queue"](cluster, vhost, name) + is_existing = True + actual = { + "auto_delete": queue["auto_delete"], + "durable": queue["durable"], + "arguments": queue["arguments"], + } + if node is not None: + actual["node"] = queue["node"] + + if actual == expected: + ret["result"] = True + ret["comment"] = f"queue {name} is up to date" + return ret + + ret["changes"] = _changes(actual, expected) + update_verb = "deleted then created back" if is_existing else "created" + + if __opts__["test"]: + ret["result"] = None + ret["comment"] = f"queue {name} will be {update_verb}" + return ret + + try: + if is_existing: + operation = "delete" + __salt__["rabbitmq_api.delete_queue"](cluster, vhost, name) + + operation = "create" + __salt__["rabbitmq_api.update_queue"](cluster, vhost, name, **expected) + except Exception as e: + e = str(e) + log.error(f"Can't {operation} RabbitMQ queue: {e}") + ret["comment"] = e + return ret + + ret["result"] = True + ret["comment"] = f"queue {update_verb}" + + return ret + + +# ------------------------------------------------------------- +# Helper functions +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def _get_password_hash(credential): + secret = __salt__["vault.read_secret"](credential) + salt = int(secret["salt"], 16) + return __salt__["rabbitmq_api.compute_password_hash_with_salt"]( + salt, secret["password"] + ) + + +def _changes(actual, expected): + """Compute a changes dictionary between actual and expected state dictionaries.""" + changes = {} + for key, value in expected.items(): + if "password" in key: + value = "*****" + + if key not in actual: + old_value = None + elif "password" in key: + old_value = "*****" + else: + old_value = actual[key] + + changes[key] = {"old": old_value, "new": value} + + return changes diff --git a/pillar/credentials/vault.sls b/pillar/credentials/vault.sls --- a/pillar/credentials/vault.sls +++ b/pillar/credentials/vault.sls @@ -26,7 +26,7 @@ # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - vault_policies_path: /srv/policies/vault -vault_policies_source: salt://roles/vault/policies/files +vault_policies_source: /srv/policies/vault vault_mount_paths: ops/secrets: ops/data/secrets diff --git a/pillar/saas/rabbitmq.sls b/pillar/saas/rabbitmq.sls new file mode 100644 --- /dev/null +++ b/pillar/saas/rabbitmq.sls @@ -0,0 +1,105 @@ +# ------------------------------------------------------------- +# Salt — RabbitMQ +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Project: Nasqueron +# License: Trivial work, not eligible to copyright +# ------------------------------------------------------------- + +# ------------------------------------------------------------- +# RabbitMQ clusters +# +# Each cluster is defined by a deployment method (e.g. docker), +# and the node we can use to configure it. +# +# The cluster configuration is a collection of vhosts and users: +# +# vhosts: +# <vhost name>: <configuration> +# +# users: +# <user>: <password FULL secret path in Vault> +# +# In addition, a root account is managed by deployment states. +# +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# +# The vhost configuration allows to define the exchanges and queues, +# and the permissions users have on them. +# +# exchanges: +# type is 'direct', 'topic' or 'fanout' +# +# queues: +# Application can create their own ephemeral queue. +# For that, it needs configure permission on the vhost. +# +# If an application needs a stable one, it should be configured here, +# so we can drop the configure permission. +# +# permissions: +# See https://www.rabbitmq.com/access-control.html#authorisation +# for the needed permissions for an AMQP operation +# +# To give access to server-generated queue names, use amq\.gen.* +# To not give any access, use blank string +# +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +rabbitmq_clusters: + white-rabbit: + deployment: docker + node: docker-002 + container: white-rabbit + url: https://white-rabbit.nasqueron.org/ + + vhosts: + + ### + ### Nasqueron dev services: + ### - Notifications center + ### + + dev: &nasqueron-dev-services-vhost + description: Nasqueron dev services + + exchanges: + # Producer: Notifications center + # Consumers: any notifications client + notifications: + type: topic + + queues: + # Used by Wearg to stream notifications to IRC + wearg-notifications: {} + + bindings: + - exchange: notifications + queue: wearg-notifications + routing_key: '#' + + permissions: + # Notifications center (paas-docker role / notifications container) + notifications: + configure: '' + read: '' + write: '^notifications$' + + # Wearg (viperserv role) + wearg: + configure: '' + read: '^wearg\-notifications$' + write: '' + + # Notifications CLI clients + notifications-ysul: ¬ifications-client-permissions + configure: '^amq\.gen.*$' + read: '^(amq\.gen.*|notifications)$' + write: '^amq\.gen.*$' + notifications-windriver: *notifications-client-permissions + + users: + # Notifications center server and clients + notifications: ops/secrets/nasqueron.notifications.broker + wearg: apps/viperserv/broker + notifications-ysul: ops/secrets/nasqueron/notifications/notifications-cli/ysul + notifications-windriver: ops/secrets/nasqueron/notifications/notifications-cli/windriver diff --git a/pillar/top.sls b/pillar/top.sls --- a/pillar/top.sls +++ b/pillar/top.sls @@ -25,6 +25,9 @@ complector: - credentials.vault + # To provision services + - saas.rabbitmq + docker-001: - credentials.zr - paas.docker diff --git a/roles/saas-rabbitmq/init.sls b/roles/saas-rabbitmq/init.sls new file mode 100644 --- /dev/null +++ b/roles/saas-rabbitmq/init.sls @@ -0,0 +1,9 @@ +# ------------------------------------------------------------- +# Salt — RabbitMQ +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Project: Nasqueron +# License: Trivial work, not eligible to copyright +# ------------------------------------------------------------- + +include: + - .server diff --git a/roles/saas-rabbitmq/server/content.sls b/roles/saas-rabbitmq/server/content.sls new file mode 100644 --- /dev/null +++ b/roles/saas-rabbitmq/server/content.sls @@ -0,0 +1,141 @@ +#!py + +# ------------------------------------------------------------- +# Salt — RabbitMQ +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Project: Nasqueron +# License: Trivial work, not eligible to copyright +# If eligible, licensed under BSD-2-Clause +# ------------------------------------------------------------- + + +# ------------------------------------------------------------- +# Configuration provider +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def run(): + config = {} + + for cluster, cluster_args in __pillar__["rabbitmq_clusters"].items(): + config |= configure_cluster(cluster, cluster_args) + + return config + + +# ------------------------------------------------------------- +# Cluster configuration +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def configure_cluster(cluster, cluster_args): + config = {} + + for user, credential in cluster_args["users"].items(): + id = f"rabbitmq_cluster_{cluster}_user_{user}" + config[id] = configure_user(cluster, user, credential) + + for vhost, vhost_args in cluster_args["vhosts"].items(): + id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}" + config[id] = configure_vhost(cluster, vhost, vhost_args) + + for exchange, exchange_args in vhost_args.get("exchanges", {}).items(): + id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_exchange_{exchange}" + config[id] = configure_exchange(cluster, vhost, exchange, exchange_args) + + for queue, queue_args in vhost_args.get("queues", {}).items(): + id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_queue_{queue}" + config[id] = configure_queue(cluster, vhost, queue, queue_args) + + i = 0 + for binding in vhost_args.get("bindings", []): + i += 1 + id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_binding_{i}" + config[id] = configure_binding(cluster, vhost, binding) + + for user, permission in vhost_args.get("permissions", {}).items(): + id = f"rabbitmq_cluster_{cluster}_vhost_{vhost}_permissions_user_{user}" + config[id] = configure_user_permission(cluster, vhost, user, permission) + + return config + + +# ------------------------------------------------------------- +# RabbitMQ vhosts +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def configure_vhost(cluster, vhost, vhost_args): + return { + "rabbitmq.vhost_present": [ + {"name": vhost}, + {"cluster": cluster}, + {"description": vhost_args.get("description", "")}, + ] + } + + +# ------------------------------------------------------------- +# RabbitMQ exchanges and queues +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def configure_exchange(cluster, vhost, exchange, exchange_args): + return { + "rabbitmq.exchange_present": [ + {"name": exchange}, + {"cluster": cluster}, + {"vhost": vhost}, + {"type": exchange_args["type"]}, + ] + } + + +def configure_queue(cluster, vhost, queue, queue_args): + return { + "rabbitmq.queue_present": [ + {"name": queue}, + {"cluster": cluster}, + {"vhost": vhost}, + ] + } + + +def configure_binding(cluster, vhost, binding): + params = [ + {"queue": binding["queue"]}, + {"cluster": cluster}, + {"vhost": vhost}, + {"exchange": binding["exchange"]}, + ] + + if "routing_key" in binding: + params.append({"routing_key": binding["routing_key"]}) + + return {"rabbitmq.queue_binding": params} + + +# ------------------------------------------------------------- +# RabbitMQ users and permissions +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + +def configure_user(cluster, user, credential): + return { + "rabbitmq.user_present": [ + {"name": user}, + {"cluster": cluster}, + {"credential": credential}, + ] + } + + +def configure_user_permission(cluster, vhost, user, privilege): + return { + "rabbitmq.user_permissions": [ + {"cluster": cluster}, + {"vhost": vhost}, + {"user": user}, + {"permissions": privilege}, + ] + } diff --git a/roles/saas-rabbitmq/server/init.sls b/roles/saas-rabbitmq/server/init.sls new file mode 100644 --- /dev/null +++ b/roles/saas-rabbitmq/server/init.sls @@ -0,0 +1,12 @@ +# ------------------------------------------------------------- +# Salt — RabbitMQ +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Project: Nasqueron +# License: Trivial work, not eligible to copyright +# ------------------------------------------------------------- + +include: + - .software + + # Content includes vhosts, exchanges, queues, users, privileges + - .content diff --git a/roles/saas-rabbitmq/server/software.sls b/roles/saas-rabbitmq/server/software.sls new file mode 100644 --- /dev/null +++ b/roles/saas-rabbitmq/server/software.sls @@ -0,0 +1,8 @@ +# ------------------------------------------------------------- +# Salt — RabbitMQ +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Project: Nasqueron +# License: Trivial work, not eligible to copyright +# ------------------------------------------------------------- + +# This state is left intentionally blank. diff --git a/roles/vault/policies/files/salt-primary.hcl b/roles/vault/policies/files/salt-primary.hcl --- a/roles/vault/policies/files/salt-primary.hcl +++ b/roles/vault/policies/files/salt-primary.hcl @@ -71,3 +71,18 @@ path "transit/keys/*"{ capabilities = ["create"] } + +# ------------------------------------------------------------- +# RabbitMQ credentials +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +{% for cluster, cluster_args in pillar.get("rabbitmq_clusters", {}).items() %} +# Cluster: {{ cluster }} + +{% for user, credential in cluster_args.get("users", {}).items() %} +path "{{ credential.replace("/", "/data/", 1) }}" { + capabilities = [ "read" ] +} +{% endfor %} + +{% endfor %} diff --git a/roles/vault/policies/init.sls b/roles/vault/policies/init.sls --- a/roles/vault/policies/init.sls +++ b/roles/vault/policies/init.sls @@ -25,6 +25,7 @@ {{ policy_path }}: file.managed: - source: salt://roles/vault/policies/files/{{ policy }}.hcl + - template: jinja vault_policy_{{ policy }}: credentials.vault_policy_present: diff --git a/services.sls b/services.sls new file mode 100644 --- /dev/null +++ b/services.sls @@ -0,0 +1,14 @@ +# ------------------------------------------------------------- +# Salt configuration for Nasqueron servers :: services +# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +# Project: Nasqueron +# Description: List of the roles configured through services API. +# They are typically run on the Salt primary server, +# especially as they can need Vault credentials, +# but they don't touch any file *directly*. +# License: Trivial work, not eligible to copyright +# ------------------------------------------------------------- + +base: + 'local': + - roles/saas-rabbitmq