diff --git a/README.md b/README.md new file mode 100644 index 0000000..a413dce --- /dev/null +++ b/README.md @@ -0,0 +1,25 @@ +# nomad-diff + +A one to one Python re-write of the Job diff formatter [from Nomad's source](https://github.com/hashicorp/nomad/blob/v0.12.3/command/job_plan.go#L371). +Use this if you communicate with Nomad over HTTP API and want to output the diff in the same format like the Nomad CLI & UI does. + +## Installation + +``` +$ pip install nomad-diff +``` + +## Usage + +``` +>>> import nomad_diff +>>> +>>> data = .. # Nomad's job diff +>>> +>>> print(nomad_diff.format(data, colors=True, verbose=False)) ++/- Job: "example" ++/- Task Group: "cache" (2 destroy, 1 in-place update) + +/- Count: "3" => "1" (forces destroy) + Task: "redis" +``` + \ No newline at end of file diff --git a/nomad_diff/__init__.py b/nomad_diff/__init__.py new file mode 100644 index 0000000..f773a5d --- /dev/null +++ b/nomad_diff/__init__.py @@ -0,0 +1,38 @@ +from .diff import format_job_diff, JobDiff +from colorama import Fore, Style # type: ignore + + +COLORS = { + '[green]': Fore.GREEN, + '[red]': Fore.RED, + '[blue]': Fore.BLUE, + '[cyan]': Fore.CYAN, + '[yellow]': Fore.YELLOW, + '[light_yellow]': Fore.LIGHTYELLOW_EX, + '[bold]': Style.BRIGHT, + '[reset]': Style.RESET_ALL +} + + +def format(diff: dict, colors: bool, verbose: bool): + diff_dict = JobDiff(**diff) + out = format_job_diff(diff_dict, verbose) + + if not colors: + return strip_colors(out) + + return colorize(out) + + +def strip_colors(diff: str): + for e in COLORS.keys(): + diff = diff.replace(e, '') + + return diff + + +def colorize(diff: str): + for p, color in COLORS.items(): + diff = diff.replace(p, color) + + return diff diff --git a/nomad_diff/diff.py b/nomad_diff/diff.py new file mode 100644 index 0000000..49f1788 --- /dev/null +++ b/nomad_diff/diff.py @@ -0,0 +1,249 @@ +from typing import List, Optional, Tuple +from .models import JobDiff, TaskGroupDiff, FieldDiff, ObjectDiff, TaskDiff + + +# https://github.com/hashicorp/nomad/blob/v0.12.3/command/job_plan.go#L371 +def format_job_diff(job: JobDiff, verbose: bool) -> str: + marker, _ = get_diff_string(job.Type) + out = f'{marker}[bold]Job: "{job.ID}"\n' + + # Determine the longest markers and fields so that the output can be + # properly aligned. + longest_field, longest_marker = get_longest_prefixes(job.Fields, job.Objects) + for tg in job.TaskGroups or []: + _, l = get_diff_string(tg.Type) + if l > longest_marker: + longest_marker = l + + # Only show the job's field and object diffs if the job is edited or + # verbose mode is set. + if job.Type == "Edited" or verbose: + fo = aligned_field_and_objects(job.Fields, job.Objects, 0, longest_field, longest_marker) + out += fo + if len(fo) > 0: + out += "\n" + + # Print the task groups + for tg in job.TaskGroups or []: + _, mLength = get_diff_string(tg.Type) + kPrefix = longest_marker - mLength + out += f'{format_task_group_diff(tg, kPrefix, verbose)}\n' + + return out + + +# https://github.com/hashicorp/nomad/blob/v0.12.3/command/job_plan.go#L408 +def format_task_group_diff(tg: TaskGroupDiff, tg_prefix: int, verbose: bool) -> str: + marker, _ = get_diff_string(tg.Type) + out = f'{marker}{" " * tg_prefix}[bold]Task Group: "{tg.Name}"[reset]' + + if tg.Updates: + order = list(tg.Updates.keys()) + order.sort() + + updates = [] + for update_type in order: + count = tg.Updates[update_type] + color = "" + + if update_type == "ignore" or update_type == "create": + color = "[green]" + elif update_type == "destroy": + color = "[red]" + elif update_type == "migrate": + color = "[blue]" + elif update_type == "in-place update": + color = "[cyan]" + elif update_type == "create/destroy update": + color = "[yellow]" + elif update_type == "canary": + color = "[light_yellow]" + + updates.append(f'[reset]{color}{count} {update_type}') + out += f' ({", ".join(updates)}[reset])\n' + else: + out += "[reset]\n" + + # Determine the longest field and markers so the output is properly + # aligned + longest_field, longest_marker = get_longest_prefixes(tg.Fields, tg.Objects) + for task in tg.Tasks or []: + _, l = get_diff_string(task.Type) + if l > longest_marker: + longest_marker = l + + # Only show the task groups's field and object diffs if the group is edited or + # verbose mode is set. + sub_start_prefix = tg_prefix + 2 + if tg.Type == "Edited" or verbose: + fo = aligned_field_and_objects(tg.Fields, tg.Objects, sub_start_prefix, longest_field, longest_marker) + out += fo + if len(fo) > 0: + out += "\n" + + # Output the tasks + for task in tg.Tasks or []: + _, mLength = get_diff_string(task.Type) + prefix = longest_marker - mLength + out += f'{format_task_diff(task, sub_start_prefix, prefix, verbose)}' + + return out + + +# https://github.com/hashicorp/nomad/blob/v0.12.3/command/job_plan.go#L481 +def format_task_diff(task: TaskDiff, startPrefix: int, taskPrefix: int, verbose: bool) -> str: + marker, _ = get_diff_string(task.Type) + out = f'{" " * startPrefix}{marker}{" " * taskPrefix}[bold]Task: "{task.Name}"' + + if task.Annotations: + out += f' [reset]({color_annotations(task.Annotations)})' + + if task.Type == "None": + return out + elif task.Type in ("Deleted", "Added") and not verbose: + # Exit early if the job was not edited and it isn't verbose output + return out + else: + out += "\n" + + sub_start_prefix = startPrefix + 2 + longest_field, longest_marker = get_longest_prefixes(task.Fields, task.Objects) + out += aligned_field_and_objects(task.Fields, task.Objects, sub_start_prefix, longest_field, longest_marker) + + return out + + +# https://github.com/hashicorp/nomad/blob/v0.12.3/command/job_plan.go#L507 +def format_object_diff(diff: ObjectDiff, start_prefix: int, key_prefix: int) -> str: + start = " " * start_prefix + marker, markerLen = get_diff_string(diff.Type) + out = f'{start}{marker}{" " * key_prefix}{diff.Name} {{\n' + + # Determine the length of the longest name and longest diff marker to + # properly align names and values + longest_field, longest_marker = get_longest_prefixes(diff.Fields, diff.Objects) + sub_start_prefix = start_prefix + key_prefix + 2 + out += aligned_field_and_objects(diff.Fields, diff.Objects, sub_start_prefix, longest_field, longest_marker) + + endprefix = " " * (start_prefix + markerLen + key_prefix) + return f'{out}\n{endprefix}}}' + + +# https://github.com/hashicorp/nomad/blob/v0.12.3/command/job_plan.go#L526 +def format_field_diff(diff: FieldDiff, start_prefix: int, key_prefix: int, value_prefix: int) -> str: + marker, _ = get_diff_string(diff.Type) + out = f'{" " * start_prefix}{marker}{" " * key_prefix}{diff.Name}: {" " * value_prefix}' + + if diff.Type == "Added": + out += f'"{diff.New}"' + elif diff.Type == "Deleted": + out += f'"{diff.Old}"' + elif diff.Type == "Edited": + out += f'"{diff.Old}" => "{diff.New}"' + else: + out += f'"{diff.New}"' + + # Color the annotations where possible + if diff.Annotations: + out += f' ({color_annotations(diff.Annotations)})' + + return out + + +# https://github.com/hashicorp/nomad/blob/v0.12.3/command/job_plan.go#L609 +def get_diff_string(diff_type: str) -> Tuple[str, int]: + if diff_type == "Added": + return f"[green]+[reset] ", 2 + elif diff_type == "Deleted": + return f"[red]-[reset] ", 2 + elif diff_type == "Edited": + return f"[light_yellow]+/-[reset] ", 4 + else: + return "", 0 + + +# https://github.com/hashicorp/nomad/blob/v0.12.3/command/job_plan.go#L590 +def get_longest_prefixes(fields: Optional[List[FieldDiff]], objects: Optional[List[ObjectDiff]]) -> Tuple[int, int]: + if fields is None: + fields = [] + if objects is None: + objects = [] + + longest_field = longest_marker = 0 + + for field in fields: + l = len(field.Name) + if l > longest_field: + longest_field = l + + _, l = get_diff_string(field.Type) + if l > longest_marker: + longest_marker = l + + for obj in objects: + _, l = get_diff_string(obj.Type) + if l > longest_marker: + longest_marker = l + + return longest_field, longest_marker + + +# https://github.com/hashicorp/nomad/blob/v0.12.3/command/job_plan.go#L555 +def aligned_field_and_objects( + fields: Optional[List[FieldDiff]], + objects: Optional[List[ObjectDiff]], + start_prefix: int, + longest_field: int, + longest_marker: int) -> str: + + if fields is None: + fields = [] + if objects is None: + objects = [] + + out = "" + num_fields = len(fields) + num_objects = len(objects) + have_objects = num_objects != 0 + + for i, field in enumerate(fields): + _, mLength = get_diff_string(field.Type) + kPrefix = longest_marker - mLength + vPrefix = longest_field - len(field.Name) + out += format_field_diff(field, start_prefix, kPrefix, vPrefix) + + # Avoid a dangling new line + if i+1 != num_fields or have_objects: + out += "\n" + + for i, object in enumerate(objects): + _, mLength = get_diff_string(object.Type) + kPrefix = longest_marker - mLength + out += format_object_diff(object, start_prefix, kPrefix) + + # Avoid a dangling new line + if i+1 != num_objects: + out += "\n" + + return out + + +# https://github.com/hashicorp/nomad/blob/v0.12.3/command/job_plan.go#L624 +def color_annotations(annotations: Optional[List[str]]) -> str: + if not annotations: + return "" + + colord = [] + for annotation in annotations: + if annotation == "forces create": + colord.append(f'[green]{annotation}[reset]') + elif annotation == "forces destroy": + colord.append(f'[red]{annotation}[reset]') + elif annotation == "forces in-place update": + colord.append(f'[cyan]{annotation}[reset]') + elif annotation == "forces create/destroy update": + colord.append(f'[yellow]{annotation}[reset]') + else: + colord.append(annotation) + + return ", ".join(colord) diff --git a/nomad_diff/models.py b/nomad_diff/models.py new file mode 100644 index 0000000..7a4942c --- /dev/null +++ b/nomad_diff/models.py @@ -0,0 +1,51 @@ +from __future__ import annotations +from typing import List, Optional, Dict + +from pydantic import BaseModel + + +class JobDiff(BaseModel): + Fields: Optional[List[FieldDiff]] + ID: str + Objects: Optional[List[ObjectDiff]] + TaskGroups: Optional[List[TaskGroupDiff]] + Type: str + + +class TaskGroupDiff(BaseModel): + Fields: Optional[List[FieldDiff]] + Name: str + Objects: Optional[List[ObjectDiff]] + Tasks: Optional[List[TaskDiff]] + Type: str + Updates: Optional[Dict[str, int]] + + +class FieldDiff(BaseModel): + Annotations: Optional[List[str]] + Name: str + New: str + Old: str + Type: str + + +class ObjectDiff(BaseModel): + Fields: List[FieldDiff] + Name: str + Objects: Optional[List[ObjectDiff]] + Type: str + + +class TaskDiff(BaseModel): + Type: str + Name: str + Fields: Optional[List[FieldDiff]] + Objects: Optional[List[ObjectDiff]] + Annotations: Optional[List[str]] + + +ObjectDiff.update_forward_refs() +JobDiff.update_forward_refs() +TaskGroupDiff.update_forward_refs() + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..7c6c8f2 --- /dev/null +++ b/setup.py @@ -0,0 +1,26 @@ +from setuptools import setup + +setup( + name='nomad-diff', + version='1.0.0', + description='Nomad job diff formatter in Python', + author='Strigo.io', + author_email='ops@strigo.io', + python_requires='>=3.7', + url='https://github.com/strigo/nomad-diff', + packages=['nomad_diff'], + install_requires=[ + 'pydantic~=1.6.1', + 'colorama~=0.4.3', + ], + include_package_data=True, + license='MIT', + classifiers=[ + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: Implementation :: CPython', + 'Topic :: Software Development :: Libraries' + ], +)