#!/usr/bin/python3
"""
Script for selectively applying Salt states based on changes in Git
Copyright (C) 2024 Georg Pfuetzenreuter <mail+opensuse@georg-pfuetzenreuter.net>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""
import logging
from argparse import ArgumentParser, RawTextHelpFormatter
from os import environ
from pathlib import PosixPath
from sys import exit
from get_roles import get_minions_with_role, get_roles_of_one_minion
from git import Repo
logging.basicConfig(format='[%(levelname)s] %(message)s')
log = logging.getLogger('salt_deploy')
modes_salt = ['ping', 'test', 'fire']
modes = ['dry'] + modes_salt
cli = False
# late import to avoid logging instance override
try:
import salt.config
import salt.loader
import salt.output
HAVE_SALT = True
except ImportError:
HAVE_SALT = False
try:
from pepper import Pepper
from pepper.exceptions import PepperException
HAVE_PEPPER = True
except ImportError:
HAVE_PEPPER = False
def _fail(msg=None, code=1, exception=None):
"""
Aborts execution with a given message and an exit code of 1
"""
if msg:
log.error(msg)
if exception and not cli:
raise exception(msg)
else:
exit(code)
def initialize_git(repository=None):
"""
Initializes a GitPython instance
"""
if repository is None:
# parent directory of the script, useful if it resides in <repository>/bin/
directory = PosixPath(__file__).resolve().parents[1]
else:
directory = repository
log.debug(f'Repository set to {directory}')
return Repo(directory)
def get_changed_files(repository):
"""
Returns a list of files which changed in the latest revision
"""
return repository.git.diff('HEAD~', name_only=True).splitlines()
def normalize_role(role):
"""
Turns the Path object of a role state file into an appliable state specifier
"""
# pillar/role/foo/init.sls -> role.foo
if role.name == 'init.sls':
return str(role.parent.relative_to(role.parts[0])).replace('/', '.')
# pillar/role/saltmaster.sls -> role.saltmaster
# pillar/role/foo/bar.sls -> role.foo.bar
elif len(role.parents) >= 3 and role.name.endswith('.sls'):
return str(role.relative_to(role.parts[0]).with_suffix('')).replace('/', '.')
else:
_fail(f'Unhandled role construct: {role} - please investigate this.', 3, RuntimeError)
def get_targets(paths): # noqa: PLR0915 # function needs more statements than usual
"""
Returns a dictionary of minions and nodegroups affected by
the given paths to files
"""
result = {'minions': {}, 'nodegroups': {}, 'patterns': {}}
def generate_minions_with_role(role):
"""
Generates a dictionary with all minions a given role is assigned to
"""
return {minion: role for minion in get_minions_with_role(role.replace('role.', ''))}
def find_roles_including_profile(profile):
"""
Returns all roles including a given profile
"""
roles = []
role_files = PosixPath('salt').rglob('*.sls')
for file in role_files:
with file.open() as fh:
if profile in fh.read():
roles.append(normalize_role(file))
return roles
def append(state, group='minions', targets=None, do_all_minions=False, do_highstate=False): # noqa: PLR0912 # logic requires deep nested walking
"""
Appends the given states to the return dictionary
"""
log.debug(f'Appending {state}, {group}, {targets}')
if do_all_minions:
group='patterns'
targets=['*']
elif targets is None:
if state.startswith('role.'):
targets = generate_minions_with_role(state.replace('role.', ''))
elif state.startswith('profile.'):
targets = {}
for role in find_roles_including_profile(state):
if role == 'role.base':
append(role, do_all_minions=True)
else:
for minion, role in generate_minions_with_role(role).items():
if minion in targets:
targets[minion].append(role)
else:
targets[minion] = [role]
elif isinstance(targets, str):
targets = [targets.replace('_', '.')]
else:
_fail(f'Cannot detect targets for {state}', 3, RuntimeError)
if do_highstate:
state = 'highstate'
for target in targets:
if target in result[group]:
if state not in result[group][target]:
result[group][target].append(state)
else:
result[group][target] = [state]
for path in paths:
log.debug(f'Parsing path {path} ...')
pp = PosixPath(path)
pps = pp.parts
ppp = pp.parent
match pps[0]:
case 'pillar' | 'salt':
match pps[1]:
case 'id':
for role in get_roles_of_one_minion(pp.stem):
append(f'role.{role}', 'minions', pp.stem)
case 'infra':
match pp.suffix:
case '.yaml':
match pp.stem:
case 'hosts':
append('highstate', 'nodegroups', 'hypervisors')
case 'alerts':
append('role.monitoring.master')
case 'domains':
append('role.nameserver.recursor')
case 'nameservers' | 'network':
append('network', do_all_minions=True)
case '.sls':
match pp.stem:
case 'init':
append('highstate', 'nodegroups', 'hypervisors')
case 'nodegroups':
append('role.saltmaster')
case 'role':
append(normalize_role(pp), do_highstate=True)
case 'profile':
profile = None
# salt/profile/foo/init.sls -> profile.foo
if pp.name == 'init.sls':
profile = str(ppp.relative_to(pp.parts[0])).replace('/', '.')
# salt/profile/foo/bar.sls -> profile.foo.bar
else:
profile = str(pp.relative_to(pp.parts[0]).with_suffix('')).replace('/', '.')
if profile is None:
_fail(f'Unhandled profile construct {pp} - please patch this!', 3, RuntimeError)
append(profile)
return result
def initialize_pepper(debug=False):
"""
Connects and authenticates to the Salt API,
returns a Pepper API instance
"""
apihost = environ.get('pepper_host')
apiuser = environ.get('pepper_user')
apipass = environ.get('pepper_secret')
if apiuser is None or apipass is None:
_fail('Please set pepper_user and pepper_secret in the environment.', exception=RuntimeError)
if apihost is None:
apihost = 'https://witch1.infra.opensuse.org:4550'
if '@' not in apiuser:
apiuser = apiuser + '@infra.opensuse.org'
api = Pepper(api_url=apihost, debug_http=debug)
try:
api_login = api.login(apiuser, apipass, 'ldap')
except PepperException as error:
_fail(f'Login failed: {error}!', RuntimeError)
log.debug(api_login)
return api
class Salt:
"""
Various operations against a given list of minions or a single
nodegroup, executed through the given Pepper API instance
"""
def __init__(self, api, minions=[], nodegroup=None, outdir=None, state_output=None, state_verbose=None):
if ( not minions and not nodegroup ) or ( minions and nodegroup ):
_fail('Illegal use of Salt().', exception=ValueError)
if isinstance(minions, str):
minions = [minions]
self.api = api
self.minions = minions
self.nodegroup = nodegroup
self.opts = salt.config.client_config('~/.config/pepper/master')
self.modules = salt.loader.minion_mods(self.opts)
self.pminions = ', '.join(self.minions)
self.outdir = outdir
if state_output:
self.opts['state_output'] = state_output
if state_verbose:
self.opts['state_verbose'] = state_verbose in ['True', 'true', True]
if self.minions:
if '*' in self.minions:
self.common_payload = {'tgt': '*', 'tgt_type': 'glob'}
else:
self.common_payload = {'tgt': self.minions, 'tgt_type': 'list'}
elif self.nodegroup:
self.common_payload = {'tgt': self.nodegroup, 'tgt_type': 'nodegroup'}
def _print(self, data, opts, outputter='nested'):
"""
Prints the Salt return data using the given outputter, aims
to mimic the default `salt` cli behavior by default
"""
# thanks to saltstack/pepper/blob/develop/pepper/script.py for providing some of this logic
state_ok = True
errors = {}
for entry in data:
if isinstance(entry, dict):
for minionid, minionret in entry.items():
if isinstance(minionret, dict):
for stateid, stateret in minionret.items():
if 'result' in stateret:
if stateret['result'] is False:
log.debug(f'Found failure in state {stateid}')
state_ok = False
if 'comment' in stateret:
errors.update({stateret.get('name', stateid): stateret['comment']})
if 'ret' in minionret:
salt.output.display_output(
{minionid: minionret['ret']},
outputter,
opts,
)
if not isinstance(minionret, dict) or 'ret' not in minionret:
salt.output.display_output(
{minionid: minionret},
outputter,
opts,
)
if not state_ok:
log.error(errors)
else:
salt.output.display_output(
{'local': entry},
outputter,
opts,
)
return state_ok
def _call(self, payload):
"""
Executes a Salt operation through the Salt API
"""
opts = self.opts
if self.outdir:
opts["output_file"] = f'{self.outdir}/{payload["tgt"]}_{payload["fun"]}.txt'
result = self.api.low(payload)
if 'return' in result:
result = result['return']
log.debug(result)
if result and ( ( isinstance(result, list) and result[0] ) or isinstance(result, dict) ):
if payload['fun'].startswith('state.'):
if self._print(result, opts=opts, outputter='highstate'):
return True
elif self._print(result, opts=opts):
return True
else:
log.warning('Did not return!')
return False
def ping(self):
"""
test.ping
"""
log.info(f'Attempting to ping {self.pminions} ...')
payload = {'client': 'local', 'fun': 'test.ping'}
payload.update(self.common_payload)
return self._call(payload)
def update(self, mine=True):
"""
saltutil.refresh_pillar + mine.update
"""
results = []
log.info(f'Refreshing {self.pminions} ...')
functions = ['saltutil.refresh_pillar']
if mine:
functions.append('mine.update')
for function in functions:
payload = {'client': 'local', 'fun': function}
log.info(f'-> {payload["fun"]}')
payload.update(self.common_payload)
results.append(self._call(payload))
if False in results:
return False
return True
def apply(self, state, test=True):
"""
state.highstate OR state.sls <state>
"""
action = 'state.highstate' if state == 'highstate' else 'state.sls'
test_msg = ' in test mode' if test else ''
log.info(f'Applying {state} on {self.pminions}{test_msg} ...')
payload = {'client': 'local', 'fun': action, 'kwarg': {'test': test}}
if state != 'highstate':
payload.update({'arg': state})
payload.update(self.common_payload)
return self._call(payload)
def coordinate(repository, mode='dry', debug=False, outdir=None, update={'pillar': True, 'mine': True}, state_output=None, state_verbose=None): # noqa: PLR0912 # too many nested if's
"""
Base application logic
"""
if mode not in modes or not isinstance(update, dict):
ValueError('Invalid function call')
DO_SALT = False
if mode in modes_salt:
DO_SALT = True
if DO_SALT and not ( HAVE_SALT and HAVE_PEPPER ):
_fail(f'Packages "salt" and "pepper" are required for the modes {modes_salt}!', exception=ImportError)
if outdir and not PosixPath(outdir).is_dir():
_fail(f'Specified directory {outdir} does not exist.', exception=FileNotFoundError)
targets = get_targets(get_changed_files(initialize_git(repository)))
if DO_SALT:
api = initialize_pepper(debug)
pinged_minions = []
updated_minions = []
for group in ['patterns', 'nodegroups', 'minions']:
log.debug(f'Walking target group "{group}"')
for target, states in targets.get(group, {}).items():
log.info(f'{target}: {states}')
if DO_SALT:
log.debug(f'Initiating Salt for {target}')
minion = Salt(api, minions=target, outdir=outdir, state_output=state_output, state_verbose=state_verbose)
if target not in pinged_minions and '*' not in pinged_minions:
log.debug(f'{target}: calling ping()')
if minion.ping():
log.debug(f'{target}: ping succeeded')
pinged_minions.append(target)
if mode in ['test', 'fire']:
if target in pinged_minions and target not in updated_minions and '*' not in updated_minions:
log.debug(f'{target}: calling update()')
update_mine = update.get('mine', True)
if ( not update.get('pillar', True) and not update_mine ) or 'highstate' in states or minion.update(mine=update_mine):
log.debug(f'{target}: update {"succeeded" if update and "highstate" not in states else "skipped"}')
updated_minions.append(target)
if minion in updated_minions and 'highstate' in states:
log.debug(f'{target}: calling apply("highstate")')
minion.apply('highstate', mode == 'test')
else:
for state in states:
log.debug(f'{target}: calling apply({state})')
minion.apply(state, mode == 'test')
def _main_cli():
choices = """
Choices for --mode:
--mode dry Skip remote operations, only gather target minions and states
--mode ping Ping the target minions
--mode test Ping the target minions, refresh their pillar and mine, and apply the states in test=True fashion
--mode fire Ping the target minions, refresh their pillar and mine, and apply the states
The pillar refresh and mine update can be avoided using --no-pillar-refresh and --no-mine-update respectively.
The "dry" mode can be run without any further requirements.
All other modes require connectivity to the Salt Master and "pepper_user" as well as "pepper_secret" to be present in the environment.
"""
argp = ArgumentParser(description='Coordinate and deploy Salt changes',
formatter_class=lambda prog: RawTextHelpFormatter(prog,max_help_position=35),
epilog=choices,
)
argp.add_argument('--repository', help='Set a custom Git repository to operate on (defaults to the parent directory of this script)')
argp.add_argument('--debug', help='Print very verbose output', action='store_const', dest='loglevel', const=logging.DEBUG, default=logging.INFO)
argp.add_argument('--api-debug', help='Print very verbose HTTP output', action='store_true')
argp.add_argument('--mode', '-m', help='Operation mode (defaults to "%(default)s")', choices=modes, default='dry', type=str, metavar='MODE')
argp.add_argument('--no-pillar-refresh', help='Skip pillar refresh (does not apply in "dry" mode)', action='store_false')
argp.add_argument('--no-mine-update', help='Skip mine update (does not apply in "dry" mode)', action='store_false')
# it would be preferable to have --out-dir print _and_ write the output, but unfortunately salt/output/__init__.py line 120 calls an early return
argp.add_argument('--out-dir', help='When applying states, write the output to files in the specified directory instead of printing it')
argp.add_argument('--state-output', help='See salt(1)')
argp.add_argument('--state-verbose', help='See salt(1)')
args = argp.parse_args()
log.setLevel(args.loglevel)
msg_onlyuseful = 'is not useful in "dry" mode, ignoring.'
if args.mode == 'dry':
if args.out_dir:
log.warning(f'--out-dir {msg_onlyuseful}')
if not args.no_pillar_refresh:
log.warning(f'--no-pillar-refresh {msg_onlyuseful}')
if not args.no_mine_update:
log.warning(f'--no-mine-update {msg_onlyuseful}')
coordinate(args.repository, mode=args.mode, debug=args.api_debug, outdir=args.out_dir, update={'pillar': args.no_pillar_refresh, 'mine': args.no_mine_update}, state_output=args.state_output, state_verbose=args.state_verbose)
if __name__ == '__main__':
cli = True
_main_cli()