mirror of
https://github.com/saltstack/salt.git
synced 2025-04-17 02:00:20 +00:00
683 lines
21 KiB
Python
683 lines
21 KiB
Python
#!/usr/bin/env python
|
|
"""
|
|
Pepa
|
|
====
|
|
|
|
Configuration templating for SaltStack using Hierarchical substitution and Jinja.
|
|
|
|
Configuring Pepa
|
|
================
|
|
|
|
.. code-block:: yaml
|
|
|
|
extension_modules: /srv/salt/ext
|
|
|
|
ext_pillar:
|
|
- pepa:
|
|
resource: host # Name of resource directory and sub-key in pillars
|
|
sequence: # Sequence used for hierarchical substitution
|
|
- hostname: # Name of key
|
|
name: input # Alias used for template directory
|
|
base_only: True # Only use templates from Base environment, i.e. no staging
|
|
- default:
|
|
- environment:
|
|
- location..region:
|
|
name: region
|
|
- location..country:
|
|
name: country
|
|
- location..datacenter:
|
|
name: datacenter
|
|
- roles:
|
|
- osfinger:
|
|
name: os
|
|
- hostname:
|
|
name: override
|
|
base_only: True
|
|
subkey: True # Create a sub-key in pillars, named after the resource in this case [host]
|
|
subkey_only: True # Only create a sub-key, and leave the top level untouched
|
|
|
|
pepa_roots: # Base directory for each environment
|
|
base: /srv/pepa/base # Path for base environment
|
|
dev: /srv/pepa/base # Associate dev with base
|
|
qa: /srv/pepa/qa
|
|
prod: /srv/pepa/prod
|
|
|
|
# Use a different delimiter for nested dictionaries, defaults to '..' since some keys may use '.' in the name
|
|
#pepa_delimiter: ..
|
|
|
|
# Supply Grains for Pepa, this should **ONLY** be used for testing or validation
|
|
#pepa_grains:
|
|
# environment: dev
|
|
|
|
# Supply Pillar for Pepa, this should **ONLY** be used for testing or validation
|
|
#pepa_pillars:
|
|
# saltversion: 0.17.4
|
|
|
|
# Enable debug for Pepa, and keep Salt on warning
|
|
#log_level: debug
|
|
|
|
#log_granular_levels:
|
|
# salt: warning
|
|
# salt.loaded.ext.pillar.pepa: debug
|
|
|
|
Pepa can also be used in Master-less SaltStack setup.
|
|
|
|
Command line
|
|
============
|
|
|
|
.. code-block:: bash
|
|
|
|
usage: pepa.py [-h] [-c CONFIG] [-d] [-g GRAINS] [-p PILLAR] [-n] [-v]
|
|
hostname
|
|
|
|
positional arguments:
|
|
hostname Hostname
|
|
|
|
optional arguments:
|
|
-h, --help show this help message and exit
|
|
-c CONFIG, --config CONFIG
|
|
Configuration file
|
|
-d, --debug Print debug info
|
|
-g GRAINS, --grains GRAINS
|
|
Input Grains as YAML
|
|
-p PILLAR, --pillar PILLAR
|
|
Input Pillar as YAML
|
|
-n, --no-color No color output
|
|
-v, --validate Validate output
|
|
|
|
Templates
|
|
=========
|
|
|
|
Templates is configuration for a host or software, that can use information from Grains or Pillars. These can then be used for hierarchically substitution.
|
|
|
|
**Example File:** host/input/test_example_com.yaml
|
|
|
|
.. code-block:: yaml
|
|
|
|
location..region: emea
|
|
location..country: nl
|
|
location..datacenter: foobar
|
|
environment: dev
|
|
roles:
|
|
- salt.master
|
|
network..gateway: 10.0.0.254
|
|
network..interfaces..eth0..hwaddr: 00:20:26:a1:12:12
|
|
network..interfaces..eth0..dhcp: False
|
|
network..interfaces..eth0..ipv4: 10.0.0.3
|
|
network..interfaces..eth0..netmask: 255.255.255.0
|
|
network..interfaces..eth0..fqdn: {{ hostname }}
|
|
cobbler..profile: fedora-19-x86_64
|
|
|
|
As you see in this example you can use Jinja directly inside the template.
|
|
|
|
**Example File:** host/region/amer.yaml
|
|
|
|
.. code-block:: yaml
|
|
|
|
network..dns..servers:
|
|
- 10.0.0.1
|
|
- 10.0.0.2
|
|
time..ntp..servers:
|
|
- ntp1.amer.example.com
|
|
- ntp2.amer.example.com
|
|
- ntp3.amer.example.com
|
|
time..timezone: America/Chihuahua
|
|
yum..mirror: yum.amer.example.com
|
|
|
|
Each template is named after the value of the key using lowercase and all extended characters are replaced with underscore.
|
|
|
|
**Example:**
|
|
|
|
osfinger: Fedora-19
|
|
|
|
**Would become:**
|
|
|
|
fedora_19.yaml
|
|
|
|
Nested dictionaries
|
|
===================
|
|
|
|
In order to create nested dictionaries as output you can use double dot **".."** as a delimiter. You can change this using "pepa_delimiter" we choose double dot since single dot is already used by key names in some modules, and using ":" requires quoting in the YAML.
|
|
|
|
**Example:**
|
|
|
|
.. code-block:: yaml
|
|
|
|
network..dns..servers:
|
|
- 10.0.0.1
|
|
- 10.0.0.2
|
|
network..dns..options:
|
|
- timeout:2
|
|
- attempts:1
|
|
- ndots:1
|
|
network..dns..search:
|
|
- example.com
|
|
|
|
**Would become:**
|
|
|
|
.. code-block:: yaml
|
|
|
|
network:
|
|
dns:
|
|
servers:
|
|
- 10.0.0.1
|
|
- 10.0.0.2
|
|
options:
|
|
- timeout:2
|
|
- attempts:1
|
|
- ndots:1
|
|
search:
|
|
- example.com
|
|
|
|
Operators
|
|
=========
|
|
|
|
Operators can be used to merge/unset a list/hash or set the key as immutable, so it can't be changed.
|
|
|
|
=========== ================================================
|
|
Operator Description
|
|
=========== ================================================
|
|
merge() Merge list or hash
|
|
unset() Unset key
|
|
immutable() Set the key as immutable, so it can't be changed
|
|
imerge() Set immutable and merge
|
|
iunset() Set immutable and unset
|
|
=========== ================================================
|
|
|
|
**Example:**
|
|
|
|
.. code-block:: yaml
|
|
|
|
network..dns..search..merge():
|
|
- foobar.com
|
|
- dummy.nl
|
|
owner..immutable(): Operations
|
|
host..printers..unset():
|
|
|
|
Validation
|
|
==========
|
|
|
|
Since it's very hard to test Jinja as is, the best approach is to run all the permutations of input and validate the output, i.e. Unit Testing.
|
|
|
|
To facilitate this in Pepa we use YAML, Jinja and Cerberus <https://github.com/nicolaiarocci/cerberus>.
|
|
|
|
Schema
|
|
======
|
|
|
|
So this is a validation schema for network configuration, as you see it can be customized with Jinja just as Pepa templates.
|
|
|
|
This was designed to be run as a build job in Jenkins or similar tool. You can provide Grains/Pillar input using either the config file or command line arguments.
|
|
|
|
**File Example: host/validation/network.yaml**
|
|
|
|
.. code-block:: jinja
|
|
|
|
network..dns..search:
|
|
type: list
|
|
allowed:
|
|
- example.com
|
|
|
|
network..dns..options:
|
|
type: list
|
|
allowed: ['timeout:2', 'attempts:1', 'ndots:1']
|
|
|
|
network..dns..servers:
|
|
type: list
|
|
schema:
|
|
regex: ^([0-9]{1,3}\\.){3}[0-9]{1,3}$
|
|
|
|
network..gateway:
|
|
type: string
|
|
regex: ^([0-9]{1,3}\\.){3}[0-9]{1,3}$
|
|
|
|
{% if network.interfaces is defined %}
|
|
{% for interface in network.interfaces %}
|
|
|
|
network..interfaces..{{ interface }}..dhcp:
|
|
type: boolean
|
|
|
|
network..interfaces..{{ interface }}..fqdn:
|
|
type: string
|
|
regex: ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-zA-Z]{2,6}$
|
|
|
|
network..interfaces..{{ interface }}..hwaddr:
|
|
type: string
|
|
regex: ^([0-9a-f]{1,2}\\:){5}[0-9a-f]{1,2}$
|
|
|
|
network..interfaces..{{ interface }}..ipv4:
|
|
type: string
|
|
regex: ^([0-9]{1,3}\\.){3}[0-9]{1,3}$
|
|
|
|
network..interfaces..{{ interface }}..netmask:
|
|
type: string
|
|
regex: ^([0-9]{1,3}\\.){3}[0-9]{1,3}$
|
|
|
|
{% endfor %}
|
|
{% endif %}
|
|
|
|
Links
|
|
=====
|
|
|
|
For more examples and information see <https://github.com/mickep76/pepa>.
|
|
"""
|
|
|
|
# Import futures
|
|
|
|
import glob
|
|
import logging
|
|
import os
|
|
import re
|
|
import sys
|
|
|
|
import jinja2
|
|
|
|
import salt.utils.files
|
|
import salt.utils.yaml
|
|
|
|
__author__ = "Michael Persson <michael.ake.persson@gmail.com>"
|
|
__copyright__ = "Copyright (c) 2013 Michael Persson"
|
|
__license__ = "Apache License, Version 2.0"
|
|
__version__ = "0.6.6"
|
|
|
|
|
|
try:
|
|
import requests
|
|
|
|
HAS_REQUESTS = True
|
|
except ImportError:
|
|
HAS_REQUESTS = False
|
|
|
|
|
|
# Only used when called from a terminal
|
|
log = None
|
|
if __name__ == "__main__":
|
|
import argparse # pylint: disable=minimum-python-version
|
|
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("hostname", help="Hostname")
|
|
parser.add_argument(
|
|
"-c", "--config", default="/etc/salt/master", help="Configuration file"
|
|
)
|
|
parser.add_argument("-d", "--debug", action="store_true", help="Print debug info")
|
|
parser.add_argument("-g", "--grains", help="Input Grains as YAML")
|
|
parser.add_argument("-p", "--pillar", help="Input Pillar as YAML")
|
|
parser.add_argument("-n", "--no-color", action="store_true", help="No color output")
|
|
parser.add_argument("-v", "--validate", action="store_true", help="Validate output")
|
|
parser.add_argument(
|
|
"-q",
|
|
"--query-api",
|
|
action="store_true",
|
|
help="Query Saltstack REST API for Grains",
|
|
)
|
|
parser.add_argument(
|
|
"--url", default="https://salt:8000", help="URL for SaltStack REST API"
|
|
)
|
|
parser.add_argument("-u", "--username", help="Username for SaltStack REST API")
|
|
parser.add_argument("-P", "--password", help="Password for SaltStack REST API")
|
|
args = parser.parse_args()
|
|
|
|
LOG_LEVEL = logging.WARNING
|
|
if args.debug:
|
|
LOG_LEVEL = logging.DEBUG
|
|
|
|
formatter = None
|
|
if not args.no_color:
|
|
try:
|
|
import colorlog # pylint: disable=import-error
|
|
|
|
formatter = colorlog.ColoredFormatter(
|
|
"[%(log_color)s%(levelname)-8s%(reset)s]"
|
|
" %(log_color)s%(message)s%(reset)s"
|
|
)
|
|
except ImportError:
|
|
formatter = logging.Formatter("[%(levelname)-8s] %(message)s")
|
|
else:
|
|
formatter = logging.Formatter("[%(levelname)-8s] %(message)s")
|
|
|
|
stream = logging.StreamHandler()
|
|
stream.setLevel(LOG_LEVEL)
|
|
stream.setFormatter(formatter)
|
|
|
|
log = logging.getLogger("pythonConfig")
|
|
log.setLevel(LOG_LEVEL)
|
|
log.addHandler(stream)
|
|
else:
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
# Options
|
|
__opts__ = {
|
|
"pepa_roots": {"base": "/srv/salt"},
|
|
"pepa_delimiter": "..",
|
|
"pepa_validate": False,
|
|
}
|
|
|
|
|
|
def __virtual__():
|
|
"""
|
|
Only return if all the modules are available
|
|
"""
|
|
if not HAS_REQUESTS:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def key_value_to_tree(data):
|
|
"""
|
|
Convert key/value to tree
|
|
"""
|
|
tree = {}
|
|
for flatkey, value in data.items():
|
|
t = tree
|
|
keys = flatkey.split(__opts__["pepa_delimiter"])
|
|
for i, key in enumerate(keys, 1):
|
|
if i == len(keys):
|
|
t[key] = value
|
|
else:
|
|
t = t.setdefault(key, {})
|
|
return tree
|
|
|
|
|
|
def ext_pillar(minion_id, pillar, resource, sequence, subkey=False, subkey_only=False):
|
|
"""
|
|
Evaluate Pepa templates
|
|
"""
|
|
roots = __opts__["pepa_roots"]
|
|
|
|
# Default input
|
|
inp = {}
|
|
inp["default"] = "default"
|
|
inp["hostname"] = minion_id
|
|
|
|
if "environment" in pillar:
|
|
inp["environment"] = pillar["environment"]
|
|
elif "environment" in __grains__:
|
|
inp["environment"] = __grains__["environment"]
|
|
else:
|
|
inp["environment"] = "base"
|
|
|
|
# Load templates
|
|
output = inp
|
|
output["pepa_templates"] = []
|
|
immutable = {}
|
|
|
|
for categ, info in [next(iter(s.items())) for s in sequence]:
|
|
if categ not in inp:
|
|
log.warning("Category is not defined: %s", categ)
|
|
continue
|
|
|
|
alias = None
|
|
if isinstance(info, dict) and "name" in info:
|
|
alias = info["name"]
|
|
else:
|
|
alias = categ
|
|
|
|
templdir = None
|
|
if info and "base_only" in info and info["base_only"]:
|
|
templdir = os.path.join(roots["base"], resource, alias)
|
|
else:
|
|
templdir = os.path.join(roots[inp["environment"]], resource, alias)
|
|
|
|
entries = []
|
|
if isinstance(inp[categ], list):
|
|
entries = inp[categ]
|
|
elif not inp[categ]:
|
|
log.warning("Category has no value set: %s", categ)
|
|
continue
|
|
else:
|
|
entries = [inp[categ]]
|
|
|
|
for entry in entries:
|
|
results_jinja = None
|
|
results = None
|
|
fn = os.path.join(templdir, re.sub(r"\W", "_", entry.lower()) + ".yaml")
|
|
if os.path.isfile(fn):
|
|
log.info("Loading template: %s", fn)
|
|
with salt.utils.files.fopen(fn) as fhr:
|
|
template = jinja2.Template(fhr.read())
|
|
output["pepa_templates"].append(fn)
|
|
|
|
try:
|
|
data = key_value_to_tree(output)
|
|
data["grains"] = __grains__.copy()
|
|
data["pillar"] = pillar.copy()
|
|
results_jinja = template.render(data)
|
|
results = salt.utils.yaml.safe_load(results_jinja)
|
|
except jinja2.UndefinedError as err:
|
|
log.error("Failed to parse JINJA template: %s\n%s", fn, err)
|
|
except salt.utils.yaml.YAMLError as err:
|
|
log.error("Failed to parse YAML in template: %s\n%s", fn, err)
|
|
else:
|
|
log.info("Template doesn't exist: %s", fn)
|
|
continue
|
|
|
|
if results is not None:
|
|
for key in results:
|
|
skey = key.rsplit(__opts__["pepa_delimiter"], 1)
|
|
rkey = None
|
|
operator = None
|
|
if len(skey) > 1 and key.rfind("()") > 0:
|
|
rkey = skey[0].rstrip(__opts__["pepa_delimiter"])
|
|
operator = skey[1]
|
|
|
|
if key in immutable:
|
|
log.warning("Key %s is immutable, changes are not allowed", key)
|
|
elif rkey in immutable:
|
|
log.warning(
|
|
"Key %s is immutable, changes are not allowed", rkey
|
|
)
|
|
elif operator == "merge()" or operator == "imerge()":
|
|
if operator == "merge()":
|
|
log.debug("Merge key %s: %s", rkey, results[key])
|
|
else:
|
|
log.debug(
|
|
"Set immutable and merge key %s: %s", rkey, results[key]
|
|
)
|
|
immutable[rkey] = True
|
|
if rkey not in output:
|
|
log.error("Cant't merge key %s doesn't exist", rkey)
|
|
elif not isinstance(results[key], type(output[rkey])):
|
|
log.error("Can't merge different types for key %s", rkey)
|
|
elif isinstance(results[key], dict):
|
|
output[rkey].update(results[key])
|
|
elif isinstance(results[key], list):
|
|
output[rkey].extend(results[key])
|
|
else:
|
|
log.error(
|
|
"Unsupported type need to be list or dict for key %s",
|
|
rkey,
|
|
)
|
|
elif operator == "unset()" or operator == "iunset()":
|
|
if operator == "unset()":
|
|
log.debug("Unset key %s", rkey)
|
|
else:
|
|
log.debug("Set immutable and unset key %s", rkey)
|
|
immutable[rkey] = True
|
|
if rkey in output:
|
|
del output[rkey]
|
|
elif operator == "immutable()":
|
|
log.debug(
|
|
"Set immutable and substitute key %s: %s",
|
|
rkey,
|
|
results[key],
|
|
)
|
|
immutable[rkey] = True
|
|
output[rkey] = results[key]
|
|
elif operator is not None:
|
|
log.error(
|
|
"Unsupported operator %s, skipping key %s", operator, rkey
|
|
)
|
|
else:
|
|
log.debug("Substitute key %s: %s", key, results[key])
|
|
output[key] = results[key]
|
|
|
|
tree = key_value_to_tree(output)
|
|
pillar_data = {}
|
|
if subkey_only:
|
|
pillar_data[resource] = tree.copy()
|
|
elif subkey:
|
|
pillar_data = tree
|
|
pillar_data[resource] = tree.copy()
|
|
else:
|
|
pillar_data = tree
|
|
if __opts__["pepa_validate"]:
|
|
pillar_data["pepa_keys"] = output.copy()
|
|
return pillar_data
|
|
|
|
|
|
def validate(output, resource):
|
|
"""
|
|
Validate Pepa templates
|
|
"""
|
|
try:
|
|
import cerberus # pylint: disable=import-error
|
|
except ImportError:
|
|
log.critical("You need module cerberus in order to use validation")
|
|
return
|
|
|
|
roots = __opts__["pepa_roots"]
|
|
|
|
valdir = os.path.join(roots["base"], resource, "validate")
|
|
|
|
all_schemas = {}
|
|
pepa_schemas = []
|
|
for fn in glob.glob(valdir + "/*.yaml"):
|
|
log.info("Loading schema: %s", fn)
|
|
with salt.utils.files.fopen(fn) as fhr:
|
|
template = jinja2.Template(fhr.read())
|
|
data = output
|
|
data["grains"] = __grains__.copy()
|
|
data["pillar"] = __pillar__.copy()
|
|
schema = salt.utils.yaml.safe_load(template.render(data))
|
|
all_schemas.update(schema)
|
|
pepa_schemas.append(fn)
|
|
|
|
val = cerberus.Validator()
|
|
if not val.validate(output["pepa_keys"], all_schemas):
|
|
for ekey, error in val.errors.items():
|
|
log.warning("Validation failed for key %s: %s", ekey, error)
|
|
|
|
output["pepa_schema_keys"] = all_schemas
|
|
output["pepa_schemas"] = pepa_schemas
|
|
|
|
|
|
# Only used when called from a terminal
|
|
if __name__ == "__main__":
|
|
# Load configuration file
|
|
if not os.path.isfile(args.config):
|
|
log.critical("Configuration file doesn't exist: %s", args.config)
|
|
sys.exit(1)
|
|
|
|
# Get configuration
|
|
with salt.utils.files.fopen(args.config) as fh_:
|
|
__opts__.update(salt.utils.yaml.safe_load(fh_))
|
|
|
|
loc = 0
|
|
for name in [next(iter(list(e.keys()))) for e in __opts__["ext_pillar"]]:
|
|
if name == "pepa":
|
|
break
|
|
loc += 1
|
|
|
|
# Get grains
|
|
__grains__ = {}
|
|
if "pepa_grains" in __opts__:
|
|
__grains__ = __opts__["pepa_grains"]
|
|
if args.grains:
|
|
__grains__.update(salt.utils.yaml.safe_load(args.grains))
|
|
|
|
# Get pillars
|
|
__pillar__ = {}
|
|
if "pepa_pillar" in __opts__:
|
|
__pillar__ = __opts__["pepa_pillar"]
|
|
if args.pillar:
|
|
__pillar__.update(salt.utils.yaml.safe_load(args.pillar))
|
|
|
|
# Validate or not
|
|
if args.validate:
|
|
__opts__["pepa_validate"] = True
|
|
|
|
if args.query_api:
|
|
import getpass
|
|
|
|
import requests
|
|
|
|
username = args.username
|
|
password = args.password
|
|
if username is None:
|
|
username = input("Username: ")
|
|
if password is None:
|
|
password = getpass.getpass()
|
|
|
|
log.info("Authenticate REST API")
|
|
auth = {"username": username, "password": password, "eauth": "pam"}
|
|
request = requests.post(args.url + "/login", auth)
|
|
|
|
if not request.ok:
|
|
raise RuntimeError(
|
|
"Failed to authenticate to SaltStack REST API: {}".format(request.text)
|
|
)
|
|
|
|
response = request.json()
|
|
token = response["return"][0]["token"]
|
|
|
|
log.info("Request Grains from REST API")
|
|
headers = {"X-Auth-Token": token, "Accept": "application/json"}
|
|
request = requests.get(args.url + "/minions/" + args.hostname, headers=headers)
|
|
|
|
result = request.json().get("return", [{}])[0]
|
|
if args.hostname not in result:
|
|
raise RuntimeError("Failed to get Grains from SaltStack REST API")
|
|
|
|
__grains__ = result[args.hostname]
|
|
|
|
# Print results
|
|
ex_subkey = False
|
|
ex_subkey_only = False
|
|
if "subkey" in __opts__["ext_pillar"][loc]["pepa"]:
|
|
ex_subkey = __opts__["ext_pillar"][loc]["pepa"]["subkey"]
|
|
if "subkey_only" in __opts__["ext_pillar"][loc]["pepa"]:
|
|
ex_subkey_only = __opts__["ext_pillar"][loc]["pepa"]["subkey_only"]
|
|
|
|
result = ext_pillar(
|
|
args.hostname,
|
|
__pillar__,
|
|
__opts__["ext_pillar"][loc]["pepa"]["resource"],
|
|
__opts__["ext_pillar"][loc]["pepa"]["sequence"],
|
|
ex_subkey,
|
|
ex_subkey_only,
|
|
)
|
|
|
|
if __opts__["pepa_validate"]:
|
|
validate(result, __opts__["ext_pillar"][loc]["pepa"]["resource"])
|
|
|
|
try:
|
|
orig_ignore = salt.utils.yaml.SafeOrderedDumper.ignore_aliases
|
|
salt.utils.yaml.SafeOrderedDumper.ignore_aliases = lambda x, y: True
|
|
|
|
def _print_result(result):
|
|
print(salt.utils.yaml.safe_dump(result, indent=4, default_flow_style=False))
|
|
|
|
if not args.no_color:
|
|
try:
|
|
# pylint: disable=import-error
|
|
import pygments
|
|
import pygments.formatters
|
|
import pygments.lexers
|
|
|
|
# pylint: disable=no-member
|
|
print(
|
|
pygments.highlight(
|
|
salt.utils.yaml.safe_dump(result),
|
|
pygments.lexers.YamlLexer(),
|
|
pygments.formatters.TerminalFormatter(),
|
|
)
|
|
)
|
|
# pylint: enable=no-member, import-error
|
|
except ImportError:
|
|
_print_result(result)
|
|
else:
|
|
_print_result(result)
|
|
finally:
|
|
# Undo monkeypatching
|
|
salt.utils.yaml.SafeOrderedDumper.ignore_aliases = orig_ignore
|