Skip to content

Commit

Permalink
Merge pull request #5 from blu-base/node_source
Browse files Browse the repository at this point in the history
Adding a Resource Model Source plugin
  • Loading branch information
blu-base authored May 7, 2024
2 parents 9d292da + 81a90ad commit ff3872c
Show file tree
Hide file tree
Showing 6 changed files with 573 additions and 2 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ and it's master.
Currently the following plugins are included:
* NodeExecutor
* FileCopier
* Resource Model Source

Use cases:
* Run Adhoc commands
Expand Down Expand Up @@ -104,6 +105,12 @@ minion.example.org:
Configuration:
* `Chunksize` can modify the chunk size send via the Salt Event bus.

### Resource Model Source
This plugin dynamically generates Nodes from the Salt API. Grains can be
selected to retrieve tags and attributes. By default all minions connected to a
salt-master are targeted, as long as they return valid information.


## Build

* Using gradle
Expand Down
238 changes: 238 additions & 0 deletions contents/salt_resource_model_source.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,238 @@
#!/usr/bin/env python -u
import logging
import sys
import json

from pepper import Pepper
from pepper.exceptions import PepperException

from common import DataItem, parse_data, sanitize_dict

log = logging.getLogger(__name__)


def configure_logging(log_level: str):
"""
Configure logging based on the provided log level.
"""
log_level = 'ERROR' if log_level != 'DEBUG' else 'DEBUG'
log.setLevel(logging.getLevelName(log_level))


def validate_required_inputs(data: dict):
"""
Validate required data items provided by Rundeck
"""
# Sanity checks for required input
for key in ['url', 'eauth', 'user', 'password']:
if not data[key]:
msg = f'No {key} specified. Command not send.'
log.error(msg)
sys.exit(1)

# Ensure defaults if parameter not set
if data['tgt'] is None:
data['tgt'] = '*'


def string_to_unique_set(src: str) -> set:
"""
Return set with unique values from input string.
"""
if isinstance(src, str):
ret = set(src.split(','))
ret.discard('')

return ret

return set()


def prepare_grains(data: dict) -> set:
"""
Prepare grains for tags and attributes.
"""
needed_grains = set(['id', 'cpuarch', 'os', 'os_family', 'osrelease', 'hostname'])

needed_tags = string_to_unique_set(data.get('tags', None))
needed_attributes = string_to_unique_set(data.get('attributes', None))

log.debug(f'Tag grains: {needed_tags}')
log.debug(f'Attribute grains: {needed_attributes}')

return needed_grains | needed_tags | needed_attributes


def collect_minions_grains(data, all_needed_grains):
"""
Execute low state API call and return minions response.
"""
# Prepare payload
low_state = {
'client': 'local',
'tgt': data['tgt'],
'fun': 'grains.item',
'arg': list(all_needed_grains),
'kwarg': {},
'full_return': True,
}

if data['timeout'] is not None:
low_state['kwarg']['timeout'] = data['timeout']
if data['gather-timeout'] is not None:
low_state['kwarg']['gather_job_timeout'] = data['gather-timeout']

log.debug(f'Compiled low_state: {low_state}')

# Login to the API
client = Pepper(api_url=data['url'], ignore_ssl_errors=not data['verify_ssl'])
try:
response = client.login(username=data['user'], password=data['password'], eauth=data['eauth'])
except PepperException as exception:
print(str(exception))
sys.exit(1)
log.debug(f'Logging into API: {response}')

# Send payload
try:
response = client.low(lowstate=[low_state])
except PepperException as exception:
print(str(exception))
sys.exit(1)
log.debug(f'Received raw response: {response}')

minions = response.get('return', [{}])[0]
return minions


def get_os_family(os_family: str) -> str:
"""
Map OS family to a standardized format.
"""
os_family_map = {
'Linux': 'unix',
'AIX': 'unix',
'MacOS': 'unix',
'VMware': 'unix',
'Windows': 'windows'
}
return os_family_map.get(os_family, os_family)


def process_tags(metadata: dict, needed_tags: set) -> set:
"""
Extract tags from grains or pillar.
"""
tags = set()
for tag in needed_tags:
tag_value = metadata.get(tag)
if tag_value is None:
continue
if isinstance(tag_value, (str, int, float)):
tags.add(str(tag_value))
elif isinstance(tag_value, list):
tags.update(str(elem) for elem in tag_value if isinstance(elem, (str, int, float)))
else:
log.warning(f'The tag {tag} is not a supported type (str, int, float, or a list of these types)')
return tags


def process_attributes(metadata, needed_attributes, reserved_keys):
"""
Process attributes from grains or pillar.
"""
processed_attributes = {}
for attribute in needed_attributes:
attribute_name = f'salt-{attribute}' if attribute in reserved_keys else attribute
attribute_value = metadata.get(attribute, '')
if isinstance(attribute_value, (str, int, float)):
processed_attributes[attribute_name] = str(attribute_value)
else:
log.warning(f'The attribute {attribute} is not a string. Nested values are not supported attribute values.')
processed_attributes[attribute_name] = ''

return processed_attributes


def generate_resource_model(minions, data):
"""
Generate resource model from minions and grains data.
"""
reserved_keys = {'nodename', 'hostname', 'username', 'description', 'tags', 'osFamily', 'osArch', 'osName',
'osVersion', 'editUrl', 'remoteUrl'}

resource_model = {}
for minion, ret in minions.items():
nodename = minion if data['prefix'] is None else f"{data['prefix']}{minion}"

if not isinstance(ret, dict) or ret.get('ret') is None:
log.warning(f'Minion {minion} does not have parsable return')
continue

grains = ret['ret']

model = {
'nodename': nodename,
'hostname': minion,
'osArch': 'x86_64' if grains['cpuarch'] in ['x86_64', 'AMD64'] else grains['cpuarch'],
'osName': grains['os'],
'osVersion': grains['osrelease'],
'osFamily': get_os_family(grains['os_family']),
'tags': list(process_tags(grains, string_to_unique_set(data['tags'])))
}

processed_attributes = process_attributes(grains, string_to_unique_set(data['attributes']), reserved_keys)
model.update(processed_attributes)

resource_model[nodename] = model

return resource_model


def main():
"""
Main function to generate ressource model
This function retrieves necessary data from environment variables provided by Rundeck.
"""
# parse environment provided by rundeck
data_items = [
DataItem('tgt', 'RD_CONFIG_TGT', 'str'),
DataItem('tags', 'RD_CONFIG_TAGS', 'str'),
DataItem('attributes', 'RD_CONFIG_ATTRIBUTES', 'str'),
DataItem('prefix', 'RD_CONFIG_PREFIX', 'str'),
DataItem('timeout', 'RD_CONFIG_TIMEOUT', 'int'),
DataItem('gather-timeout', 'RD_CONFIG_GATHER_TIMEOUT', 'int'),
DataItem('url', 'RD_CONFIG_URL', 'str'),
DataItem('eauth', 'RD_CONFIG_EAUTH', 'str'),
DataItem('user', 'RD_CONFIG_USER', 'str'),
DataItem('password', 'RD_CONFIG_PASSWORD', 'str'),
DataItem('verify_ssl', 'RD_CONFIG_VERIFYSSL', 'bool'),
DataItem('log-level', 'RD_JOB_LOGLEVEL', 'str'),
]

data = parse_data(data_items)
log.debug(f"Data: {sanitize_dict(data, ['password'])}")

# use rundeck's log level if defined
configure_logging(data['log-level'])

# sanity checks
validate_required_inputs(data)

# queue the Salt-API
all_needed_grains = prepare_grains(data)
minions = collect_minions_grains(data, all_needed_grains)

# compile the Rundeck Resource Model
resource_model = generate_resource_model(minions, data)

# print response to stdout for Rundeck to pickup
print(json.dumps(resource_model))

sys.exit(0)


if __name__ == '__main__':
main()

84 changes: 83 additions & 1 deletion plugin.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,86 @@ providers:
default: true
scope: Project
renderingOptions:
groupName: API
groupName: API
- name: salt-resource-model-source
service: ResourceModelSource
title: Salt Minion Resource Model Source
description: Use Salt minions as node source
plugin-type: script
script-interpreter: python -u
script-file: salt_resource_model_source.py
script-args: ''
resource-format: resourcejson
config:
- type: String
name: prefix
title: 'Nodename prefix'
description: "Optionally prefix the nodename. By default no prefix is used, hence the nodename equals the minion's id"
- type: String
name: tgt
title: 'Minion target'
description: "Use salt's compound targeting to control the generated notes. Defaults to '*'"
scope: Project
default: '*'
- type: String
name: tags
title: 'Node tags'
description: 'Create node tags from the values of grains. Use comma-separated list for multiple grains.'
scope: Project
- type: String
name: attributes
title: 'Node attributes'
description: 'Create node attributes from grains and their values. use comma-separated list for multiple grains. Nested values of grains are not supported, but nested keys are, such as systemd.version .'
scope: Project
default: 'master'
- type: Integer
name: timeout
title: 'Minion timeout'
description: 'Specify minion timeout to gather grains'
scope: Project
default: 10
- type: Integer
name: gather-timeout
title: 'Gather timeout'
description: 'Specify the master can wait for responses of minions'
scope: Project
default: 20
- type: String
name: url
title: 'API URL'
description: 'Address for the Salt-API endpoint, e.g. https://salt.example.com:9080'
scope: Project
renderingOptions:
groupName: API
- type: String
name: eauth
title: 'Eauth Module'
description: 'Configured backend for authenticating the credentials'
scope: Project
renderingOptions:
groupName: API
- type: String
name: user
title: Username
description: 'User or identifier used to authenticate with the Salt-API'
scope: Project
renderingOptions:
groupName: API
- type: String
name: password
title: Password
description: 'Key storage path for the pasword or secret used to authenticate with the Salt-API'
scope: Project
renderingOptions:
selectionAccessor: STORAGE_PATH
valueConversion: STORAGE_PATH_AUTOMATIC_READ
storage-file-meta-filter: "Rundeck-data-type=password"
groupName: API
- type: Boolean
name: verifySSL
title: 'Verify SSL'
description: 'Whether the script should verify the SSL connection to the Salt-API endpoint; Defaults to true'
default: true
scope: Project
renderingOptions:
groupName: API
3 changes: 2 additions & 1 deletion tests/integration/test_salt_file_copier.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ def test_empty_destination_file(rundeck_environment_base, session_minion_id, ses
assert sys_exit.value.code == 1


@pytest.mark.parametrize("file_length", [1000, 1024*3072])
@pytest.mark.parametrize("file_length", [1000, 1024*2])
def test_file_transfer(rundeck_environment_base, session_minion_id, session_salt_api, file_length, tmp_path, capsys):
assert session_salt_api.is_running()

Expand All @@ -115,6 +115,7 @@ def test_file_transfer(rundeck_environment_base, session_minion_id, session_salt
'RD_NODE_HOSTNAME': session_minion_id,
'RD_FILE_COPY_FILE': str(src),
'RD_FILE_COPY_DESTINATION': str(dest),
'RD_CONFIG_SALT_FILE_COPY_CHUNK_SIZE': str(1024),
})

# create test file
Expand Down
Loading

0 comments on commit ff3872c

Please sign in to comment.