From c338e2d9b7794d44b0b4c95d52eaf7d1e9c9b14d Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Fri, 8 May 2026 14:20:07 -0700 Subject: [PATCH 1/2] Added `state.graph` and `state.graph_highstate` Added `state.graph` and `state.graph_highstate` execution modules and runners to generate a DOT representation of the state dependency graph. --- changelog/69091.added.md | 1 + salt/modules/state.py | 109 +++++++++++++++++ salt/runners/state.py | 114 ++++++++++++++++++ salt/utils/requisite.py | 28 +++++ .../utils/requisite/test_dependency_graph.py | 37 ++++++ 5 files changed, 289 insertions(+) create mode 100644 changelog/69091.added.md diff --git a/changelog/69091.added.md b/changelog/69091.added.md new file mode 100644 index 000000000000..68246a888f09 --- /dev/null +++ b/changelog/69091.added.md @@ -0,0 +1 @@ +Added `state.graph` and `state.graph_highstate` execution modules and runners to generate a DOT representation of the state dependency graph. diff --git a/salt/modules/state.py b/salt/modules/state.py index 7b4437a28aad..fe62b1f4e40a 100644 --- a/salt/modules/state.py +++ b/salt/modules/state.py @@ -1818,6 +1818,70 @@ def show_highstate(queue=None, **kwargs): return ret +def graph_highstate(queue=None, **kwargs): + """ + Retrieve the highstate data from the salt master and display it as a + dependency graph in DOT format. + + Custom Pillar data can be passed with the ``pillar`` kwarg. + + CLI Example: + + .. code-block:: bash + + salt '*' state.graph_highstate + """ + conflict = _check_queue(queue, kwargs) + if conflict is not None: + return conflict + pillar_override = kwargs.get("pillar") + pillar_enc = kwargs.get("pillar_enc") + if ( + pillar_enc is None + and pillar_override is not None + and not isinstance(pillar_override, dict) + ): + raise SaltInvocationError( + "Pillar data must be formatted as a dictionary, unless pillar_enc " + "is specified." + ) + + opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) + try: + st_ = salt.state.HighState( + opts, + pillar_override, + pillar_enc=pillar_enc, + proxy=__proxy__, + initial_pillar=_get_initial_pillar(opts), + ) + except NameError: + st_ = salt.state.HighState( + opts, + pillar_override, + pillar_enc=pillar_enc, + initial_pillar=_get_initial_pillar(opts), + ) + + with st_: + errors = _get_pillar_errors(kwargs, pillar=st_.opts["pillar"]) + if errors: + __context__["retcode"] = salt.defaults.exitcodes.EX_PILLAR_FAILURE + raise CommandExecutionError("Pillar failed to render", info=errors) + + st_.push_active() + try: + high = st_.compile_highstate() + finally: + st_.pop_active() + + if not isinstance(high, dict): + return high + + st_.state.compile_high_data(high) + return st_.state.dependency_dag.to_dot() + + def show_lowstate(queue=None, **kwargs): """ List out the low data that will be applied to this minion @@ -2269,6 +2333,51 @@ def show_sls(mods, test=None, queue=None, **kwargs): return high_ +def graph(mods, test=None, queue=None, **kwargs): + """ + Display the dependency graph from a specific sls or list of sls files on the + master. The output is in DOT format. + + Custom Pillar data can be passed with the ``pillar`` kwarg. + + saltenv + Specify a salt fileserver environment to be used when applying states + + pillarenv + Specify a Pillar environment to be used when applying states. This + can also be set in the minion config file using the + :conf_minion:`pillarenv` option. When neither the + :conf_minion:`pillarenv` minion config option nor this CLI argument is + used, all Pillar environments will be merged together. + + CLI Example: + + .. code-block:: bash + + salt '*' state.graph core,edit.vim saltenv=dev + """ + high = show_sls(mods, test=test, queue=queue, **kwargs) + if not isinstance(high, dict): + return high + + opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) + try: + st_ = salt.state.State( + opts, + proxy=dict(__proxy__), + context=dict(__context__), + initial_pillar=_get_initial_pillar(opts), + ) + except NameError: + st_ = salt.state.State( + opts, + initial_pillar=_get_initial_pillar(opts), + ) + + st_.compile_high_data(high) + return st_.dependency_dag.to_dot() + + def sls_exists(mods, test=None, queue=None, **kwargs): """ Tests for the existence of a specific SLS or list of SLS files on the diff --git a/salt/runners/state.py b/salt/runners/state.py index e0e41a659ba9..efa3b6d2468f 100644 --- a/salt/runners/state.py +++ b/salt/runners/state.py @@ -4,6 +4,7 @@ import logging +import salt.client import salt.loader import salt.utils.event import salt.utils.functools @@ -256,6 +257,119 @@ def orchestrate_show_sls( ) +def graph( + mods, + tgt=None, + tgt_type="glob", + saltenv="base", + test=None, + pillar=None, + pillarenv=None, + pillar_enc=None, + timeout=None, +): + """ + Display the dependency graph from a specific sls or list of sls files. + + If ``tgt`` is provided, the graph will be generated for the specified + minion(s) by calling the ``state.graph`` execution module on them. + + If ``tgt`` is not provided, the graph will be generated for the master + minion (orchestration context). + + CLI Example: + + .. code-block:: bash + + salt-run state.graph webserver + salt-run state.graph webserver tgt='*' + salt-run state.graph webserver tgt=minion1 timeout=60 + """ + if pillar is not None and not isinstance(pillar, dict): + raise SaltInvocationError("Pillar data must be formatted as a dictionary") + + if tgt: + client = salt.client.LocalClient(__opts__["conf_file"]) + return client.cmd( + tgt, + "state.graph", + [mods], + kwarg={ + "test": test, + "pillar": pillar, + "saltenv": saltenv, + "pillarenv": pillarenv, + "pillar_enc": pillar_enc, + }, + tgt_type=tgt_type, + timeout=timeout or __opts__["timeout"], + ) + + __opts__["file_client"] = "local" + minion = salt.minion.MasterMinion(__opts__) + + return minion.functions["state.graph"]( + mods, + test=test, + pillar=pillar, + saltenv=saltenv, + pillarenv=pillarenv, + pillar_enc=pillar_enc, + ) + + +def graph_highstate( + tgt=None, + tgt_type="glob", + pillar=None, + pillarenv=None, + pillar_enc=None, + timeout=None, +): + """ + Display the dependency graph for the highstate. + + If ``tgt`` is provided, the graph will be generated for the specified + minion(s) by calling the ``state.graph_highstate`` execution module on them. + + If ``tgt`` is not provided, the graph will be generated for the master + minion (orchestration context). + + CLI Example: + + .. code-block:: bash + + salt-run state.graph_highstate + salt-run state.graph_highstate tgt='*' + salt-run state.graph_highstate tgt=minion1 timeout=60 + """ + if pillar is not None and not isinstance(pillar, dict): + raise SaltInvocationError("Pillar data must be formatted as a dictionary") + + if tgt: + client = salt.client.LocalClient(__opts__["conf_file"]) + return client.cmd( + tgt, + "state.graph_highstate", + kwarg={ + "pillar": pillar, + "pillarenv": pillarenv, + "pillar_enc": pillar_enc, + }, + tgt_type=tgt_type, + timeout=timeout or __opts__["timeout"], + ) + + __opts__["file_client"] = "local" + minion = salt.minion.MasterMinion(__opts__) + + return minion.functions["state.graph_highstate"]( + pillar=pillar, + pillarenv=pillarenv, + pillar_enc=pillar_enc, + ) + + def event( tagmatch="*", count=-1, quiet=False, sock_dir=None, pretty=False, node="master" ): diff --git a/salt/utils/requisite.py b/salt/utils/requisite.py index 22f51cab34ca..55a8da650255 100644 --- a/salt/utils/requisite.py +++ b/salt/utils/requisite.py @@ -699,6 +699,34 @@ def get_cycles_str(self) -> str: ] return ", ".join(cycle_edges) + def to_dot(self) -> str: + """ + Return a DOT representation of the dependency graph + """ + dot = ["digraph G {"] + # Add nodes + for node_id, node_data in self.dag.nodes.items(): + chunk = node_data.get("chunk") + if chunk: + label = f"{chunk['state']}.{chunk['fun']}\\n{chunk['__id__']}" + if chunk["name"] != chunk["__id__"]: + label += f"\\n({chunk['name']})" + dot.append(f' "{node_id}" [label="{label}"];') + elif "aggregated_nodes" in node_data: + dot.append( + f' "{node_id}" [label="Aggregate {node_data["state"]}", shape=box];' + ) + else: + dot.append(f' "{node_id}" [label="{node_id}"];') + + # Add edges + for source_node in self.dag.nodes: + for source, target, req_type in self.dag.out_edges(source_node, keys=True): + dot.append(f' "{source}" -> "{target}" [label="{req_type}"];') + + dot.append("}") + return "\n".join(dot) + def get_dependencies( self, low: LowChunk ) -> Generator[tuple[RequisiteType, LowChunk], None, None]: diff --git a/tests/pytests/unit/utils/requisite/test_dependency_graph.py b/tests/pytests/unit/utils/requisite/test_dependency_graph.py index 37d3ee62dc59..c72248eb206f 100644 --- a/tests/pytests/unit/utils/requisite/test_dependency_graph.py +++ b/tests/pytests/unit/utils/requisite/test_dependency_graph.py @@ -533,3 +533,40 @@ def test_get_dependencies_when_aggregated(): for (req_type, chunk) in depend_graph.get_dependencies(low) ] assert expected_dependency_tuples == depend_tuples + + +def test_to_dot(): + sls = "test" + env = "base" + chunks = [ + { + "__id__": "pkg_install", + "name": "vim", + "state": "pkg", + "fun": "installed", + }, + { + "__id__": "conf_file", + "name": "/etc/vimrc", + "state": "file", + "fun": "managed", + "require": [{"pkg": "vim"}], + }, + ] + depend_graph = salt.utils.requisite.DependencyGraph() + for low in chunks: + low.update( + { + "__env__": env, + "__sls__": sls, + } + ) + depend_graph.add_chunk(low, allow_aggregate=False) + for low in chunks: + depend_graph.add_requisites(low, []) + + dot = depend_graph.to_dot() + assert "digraph G {" in dot + assert "pkg_install" in dot + assert "conf_file" in dot + assert "require" in dot From 1e543610f678c1691cfd35abb4c793af48b9827b Mon Sep 17 00:00:00 2001 From: "Daniel A. Wozniak" Date: Sat, 9 May 2026 03:15:39 -0700 Subject: [PATCH 2/2] Add tgt/timeout support and image generation docs for state.graph --- salt/modules/state.py | 16 ++++++++++++++++ salt/runners/state.py | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/salt/modules/state.py b/salt/modules/state.py index fe62b1f4e40a..5666e3d16f02 100644 --- a/salt/modules/state.py +++ b/salt/modules/state.py @@ -1830,6 +1830,14 @@ def graph_highstate(queue=None, **kwargs): .. code-block:: bash salt '*' state.graph_highstate + + **Generating an Image:** + + The DOT output can be rendered to an image using Graphviz: + + .. code-block:: bash + + salt '*' state.graph_highstate --out=txt | dot -Tpng -o highstate.png """ conflict = _check_queue(queue, kwargs) if conflict is not None: @@ -2355,6 +2363,14 @@ def graph(mods, test=None, queue=None, **kwargs): .. code-block:: bash salt '*' state.graph core,edit.vim saltenv=dev + + **Generating an Image:** + + The DOT output can be rendered to an image using Graphviz: + + .. code-block:: bash + + salt '*' state.graph core --out=txt | dot -Tpng -o state_graph.png """ high = show_sls(mods, test=test, queue=queue, **kwargs) if not isinstance(high, dict): diff --git a/salt/runners/state.py b/salt/runners/state.py index efa3b6d2468f..7941bbed91b1 100644 --- a/salt/runners/state.py +++ b/salt/runners/state.py @@ -284,6 +284,14 @@ def graph( salt-run state.graph webserver salt-run state.graph webserver tgt='*' salt-run state.graph webserver tgt=minion1 timeout=60 + + **Generating an Image:** + + The DOT output can be rendered to an image using Graphviz: + + .. code-block:: bash + + salt-run state.graph webserver | dot -Tpng -o state_graph.png """ if pillar is not None and not isinstance(pillar, dict): raise SaltInvocationError("Pillar data must be formatted as a dictionary") @@ -342,6 +350,14 @@ def graph_highstate( salt-run state.graph_highstate salt-run state.graph_highstate tgt='*' salt-run state.graph_highstate tgt=minion1 timeout=60 + + **Generating an Image:** + + The DOT output can be rendered to an image using Graphviz: + + .. code-block:: bash + + salt-run state.graph_highstate | dot -Tpng -o highstate.png """ if pillar is not None and not isinstance(pillar, dict): raise SaltInvocationError("Pillar data must be formatted as a dictionary")