Skip to content

Commit

Permalink
Rework inventory cli
Browse files Browse the repository at this point in the history
With the changes we've been making in 0.6 toward a better, more
customizable and visually appealing, user experience. It's time that we
revisited the inventory command.
The --curated view introduced in 0.5.x has now become the default
inventory view. The original default view is now present in its new form
and home with the --list flag.
The --details flag largely retains the functionality it did before.
However, it involves much less processing than before.

One important piece added in this is the ability to customize the
inventory fields on a per-user basis. The details on this customization
are documented in detail in the supporting code.
It will also be important to document this in the wiki upon release.
  • Loading branch information
JacobCallahan committed Oct 7, 2024
1 parent 4c9a6dc commit a76d0ea
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 57 deletions.
55 changes: 26 additions & 29 deletions broker/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,49 +285,46 @@ def checkin(hosts, background, all_, sequential, filter):

@loggedcli()
@click.option("--details", is_flag=True, help="Display all host details")
@click.option("--curated", is_flag=True, help="Display curated host details")
@click.option("--list", "_list", is_flag=True, help="Display only hostnames and local ids")
@click.option(
"--sync",
type=str,
help="Class-style name of a supported broker provider. (AnsibleTower)",
)
@click.option("--filter", type=str, help="Display only what matches the specified filter")
def inventory(details, curated, sync, filter):
def inventory(details, _list, sync, filter):
"""Get a list of all hosts you've checked out showing hostname and local id.
hostname pulled from list of dictionaries.
Inventory fields are configurable in Broker's settings file.
Note: Applying a filter will result in incorrect id's being displayed.
"""
if sync:
Broker.sync_inventory(provider=sync)
logger.info("Pulling local inventory")
inventory = helpers.load_inventory(filter=filter)
helpers.emit({"inventory": inventory})
if curated:
table = Table(title="Host Inventory")

table.add_column("Id", justify="left", style="cyan", no_wrap=True)
table.add_column("Host", justify="left", style="magenta")
table.add_column("Provider", justify="left", style="green")
table.add_column("Action", justify="left", style="yellow")
table.add_column("OS", justify="left", style="blue")

for host in helpers.get_host_inventory_fields(inventory, PROVIDER_ACTIONS):
table.add_row(
str(host["id"]), host["host"], host["provider"], host["action"], host["os"]
)

CONSOLE.print(table)
# details is handled differently than the normal and list views
if details:
detailed = helpers.yaml_format(dict(enumerate(inventory)))
CONSOLE.print(Syntax(detailed, "yaml", background_color="default"))
return
for num, host in enumerate(inventory):
if (display_name := host.get("hostname")) is None:
display_name = host.get("name")
# if we're filtering, then don't show an index.
# Otherwise, a user might perform an action on the incorrect (unfiltered) index.
index = f"{num}: " if filter is None else ""
if details:
logger.info(f"{index}{display_name}:\n{helpers.yaml_format(host)}")
else:
logger.info(f"{index}{display_name}")

inventory_fields = (
{"Host": "hostname | name | ip"} if _list else settings.settings.inventory_fields
)
curated_host_info = [
helpers.inventory_fields_to_dict(
inventory_fields=inventory_fields,
host_dict=host,
provider_actions=PROVIDER_ACTIONS,
)
for host in inventory
]
table = helpers.dictlist_to_table(curated_host_info, "Host Inventory", _id=True)
if _list:
table.title = None
table.box = None
CONSOLE.print(table)


@loggedcli()
Expand Down
23 changes: 23 additions & 0 deletions broker/config_migrations/v0_6_0.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Config migrations for versions older than 0.6.0 to 0.6.0."""

from logzero import logger

TO_VERSION = "0.6.0"
Expand Down Expand Up @@ -60,6 +61,28 @@ def add_thread_limit(config_dict):
return config_dict


def add_inventory_fields(config_dict):
"""Inventory fields are new in this version.
Example:
# Customize the fields and values presented by `broker inventory`
# Almost all field values should correspond to a field in your Broker inventory
inventory_fields:
Host: hostname | name # use a | to allow fallback values
Provider: _broker_provider # just pull the _broker_provider value
Action: $action # some special field values are possible, check the wiki
OS: os_distribution os_distribution_version # you can combine multiple values with a space between
"""
logger.debug("Adding inventory fields to the config.")
config_dict["inventory_fields"] = {
"Host": "hostname",
"Provider": "_broker_provider",
"Action": "$action",
"OS": "os_distribution os_distribution_version",
}
return config_dict


def run_migrations(config_dict):
"""Run all migrations."""
logger.info(f"Running config migrations for {TO_VERSION}.")
Expand Down
133 changes: 105 additions & 28 deletions broker/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

import click
from logzero import logger
from rich.table import Table
from ruamel.yaml import YAML

from broker import exceptions, logger as b_log, settings
Expand All @@ -30,6 +31,18 @@
yaml.default_flow_style = False
yaml.sort_keys = False

SPECIAL_INVENTORY_FIELDS = {} # use the _special_inventory_field decorator to add new fields


def _special_inventory_field(action_name):
"""Register inventory field actions."""

def decorator(func):
SPECIAL_INVENTORY_FIELDS[action_name] = func
return func

return decorator


def clean_dict(in_dict):
"""Remove entries from a dict where value is None."""
Expand Down Expand Up @@ -98,7 +111,7 @@ def flatten_dict(nested_dict, parent_key="", separator="_"):
return dict(flattened)


def dict_from_paths(source_dict, paths):
def dict_from_paths(source_dict, paths, sep="/"):
"""Given a dictionary of desired keys and nested paths, return a new dictionary.
Example:
Expand All @@ -122,10 +135,10 @@ def dict_from_paths(source_dict, paths):
"""
result = {}
for key, path in paths.items():
if "/" not in path:
if sep not in path:
result[key] = source_dict.get(path)
else:
top, rem = path.split("/", 1)
top, rem = path.split(sep, 1)
result.update(dict_from_paths(source_dict[top], {key: rem}))
return result

Expand Down Expand Up @@ -279,32 +292,73 @@ def flip_provider_actions(provider_actions):
return flipped


def get_host_inventory_fields(inv_dict, provider_actions):
def inventory_fields_to_dict(inventory_fields, host_dict, **extras):
"""Convert a dicionary-like representation of inventory fields to a resolved dictionary.
inventory fields, as set in the config look like this, in yaml:
inventory_fields:
Host: hostname | name
Provider: _broker_provider
Action: $action
OS: os_distribution os_distribution_version
We then process that into a dictionary with inventory values like this:
{
"Host": "some.test.host",
"Provider": "AnsibleTower",
"Action": "deploy-base-rhel",
"OS": "RHEL 8.4"
}
Notes: The special syntax use in Host and Action fields <$action> is a special keyword that
represents a more complex field resolved by Broker.
Also, the Host field represents a priority order of single values,
so if hostname is not present, name will be used.
Finally, spaces between values are preserved. This lets us combine multiple values in a single field.
"""
return {
name: _resolve_inv_field(field, host_dict, **extras)
for name, field in inventory_fields.items()
}


def _resolve_inv_field(field, host_dict, **extras):
"""Real functionality for inventory_fields_to_dict, allows recursive evaluation."""
# Users can specify multiple values to try in order of priority, so evaluate each
if "|" in field:
resolved = [_resolve_inv_field(f.strip(), host_dict, **extras) for f in field.split("|")]
for val in resolved:
if val:
return val
return "Unknown"
# Users can combine multiple values in a single field, so evaluate each
if " " in field:
return " ".join(_resolve_inv_field(f, host_dict, **extras) for f in field.split())
# Some field values require special handling beyond what the existing syntax allows
if special_field_func := SPECIAL_INVENTORY_FIELDS.get(field):
return special_field_func(host_dict, **extras)
# Otherwise, try to get the value from the host dictionary
return dict_from_paths(host_dict, {"_": field}, sep=".")["_"] or "Unknown"


@_special_inventory_field("$action")
def get_host_action(host_dict, provider_actions=None, **_):
"""Get a more focused set of fields from the host inventory."""
flipped_prov_actions = flip_provider_actions(provider_actions)
curated_hosts = []
for num, host in enumerate(inv_dict):
match host:
case {
"name": name,
"hostname": hostname,
"_broker_provider": provider,
}:
os_name = host.get("os_distribution", "Unknown")
os_version = host.get("os_distribution_version", "")
for opt in flipped_prov_actions[provider]:
if action := host["_broker_args"].get(opt):
curated_hosts.append(
{
"id": num,
"host": hostname or name,
"provider": provider,
"os": f"{os_name} {os_version}",
"action": action,
}
)
break
return curated_hosts
if not provider_actions:
return "$actionError"
# Flip the mapping of actions->provider to provider->actions
flipped_actions = {}
for action, (provider, _) in provider_actions.items():
provider_name = provider.__name__
if provider_name not in flipped_actions:
flipped_actions[provider_name] = []
flipped_actions[provider_name].append(action)
# Get the host's action, based on its provider
provider = host_dict["_broker_provider"]
for opt in flipped_actions[provider]:
if action := host_dict["_broker_args"].get(opt):
return action
return "Unknown"


def kwargs_from_click_ctx(ctx):
Expand Down Expand Up @@ -657,3 +711,26 @@ def temporary_tar(paths):
tar.add(path, arcname=path.name)
yield temp_tar.absolute()
temp_tar.unlink()


def dictlist_to_table(dict_list, title=None, _id=False):
"""Convert a list of dictionaries to a rich table."""
# I like pretty colors, so let's cycle through them
column_colors = ["cyan", "magenta", "green", "yellow", "blue", "red"]
curr_color = 0
table = Table(title=title)
# construct the columns
if _id: # likely just for inventory tables
table.add_column("Id", justify="left", style=column_colors[curr_color], no_wrap=True)
curr_color += 1
for key in dict_list[0]: # assume all dicts have the same keys
table.add_column(key, justify="left", style=column_colors[curr_color])
curr_color += 1
if curr_color >= len(column_colors):
curr_color = 0
# add the rows
for id_num, data_dict in enumerate(dict_list):
row = [str(id_num)] if _id else []
row.extend([str(value) for value in data_dict.values()])
table.add_row(*row)
return table
7 changes: 7 additions & 0 deletions broker_settings.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ _version: 0.6.0
logging:
console_level: info
file_level: debug
# Customize the fields and values presented by `broker inventory`
# Almost all field values should correspond to a field in your Broker inventory
inventory_fields:
Host: hostname | name # use a | to allow fallback values
Provider: _broker_provider # just pull the _broker_provider value
Action: $action # some special field values are possible, check the wiki
OS: os_distribution os_distribution_version # you can combine multiple values with a space between
# Optionally set a limit for the number of threads Broker can use for actions
thread_limit: None
# Host SSH Settings
Expand Down

0 comments on commit a76d0ea

Please sign in to comment.