Skip to content

Commit 7d5007a

Browse files
committed
Show live capability dependency status
1 parent 8864050 commit 7d5007a

3 files changed

Lines changed: 260 additions & 22 deletions

File tree

src/automax/cli/cli.py

Lines changed: 92 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from automax import __version__
2222
from automax.core.engine import AutomaxEngine, AutomaxError
23+
from automax.core.capabilities import package_for_tool
2324
from automax.core.inventory import Inventory, InventoryError, load_inventory_document
2425
from automax.core.known_hosts import KnownHostsError, scan_known_hosts, write_known_hosts
2526
from automax.core.plugin_docs import render_plugin_reference
@@ -121,55 +122,125 @@ def _echo_vars_payload(payload: Dict[str, Any], output_format: str) -> None:
121122
click.echo(f" {node['node_id']} {node['plugin']}")
122123

123124

125+
def _package_for_display(tool: str, target: Dict[str, Any]) -> str | None:
126+
family = target.get("os", {}).get("family", "unknown")
127+
return package_for_tool(tool, family)
128+
129+
124130
def _echo_capabilities_payload(payload: Dict[str, Any], output_format: str) -> None:
125131
if output_format == "json":
126132
click.echo(json.dumps(payload, indent=2, sort_keys=True))
127133
return
128134
click.echo(f"Job: {payload['job']}")
129-
click.echo(f"Targets: {payload['target_count']} Tools: {payload['tool_count']} Packages: {payload.get('package_count', 0)}")
135+
click.echo(
136+
f"Targets: {payload['target_count']} "
137+
f"Tools: {payload['tool_count']} "
138+
f"Packages: {payload.get('package_count', 0)} "
139+
f"Missing tools: {payload.get('missing_tool_count', 0)} "
140+
f"Missing packages: {payload.get('missing_package_count', 0)}"
141+
)
130142
for target in payload["targets"]:
131143
click.echo(f"Target {target['target']} {target['host']} os={target.get('os', {}).get('family', 'unknown')} id={target.get('os', {}).get('id', 'unknown')}")
132144
if target.get("skipped_plugins"):
133145
click.echo(" skipped OS-mismatched plugins:")
134146
for skipped in target["skipped_plugins"]:
135147
click.echo(f" {skipped['plugin']}: {skipped['reason']}")
136148
if target.get("packages"):
137-
click.echo(" packages:")
149+
click.echo(" required packages:")
138150
for package in target["packages"]:
139151
click.echo(f" {package}")
152+
if "missing_tools" in target:
153+
if target.get("missing_tools"):
154+
click.echo(" missing tools:")
155+
for tool in target["missing_tools"]:
156+
package = _package_for_display(tool, target)
157+
suffix = f" -> package {package}" if package else " -> package unknown"
158+
click.echo(f" {tool}{suffix}")
159+
else:
160+
click.echo(" missing tools: none")
161+
if target.get("missing_packages"):
162+
click.echo(" missing packages:")
163+
for package in target["missing_packages"]:
164+
click.echo(f" {package}")
165+
else:
166+
click.echo(" missing packages: none")
167+
if target.get("unresolved_tools"):
168+
click.echo(" unresolved tools:")
169+
for tool in target["unresolved_tools"]:
170+
click.echo(f" {tool}")
140171
if not target["tools"]:
141172
click.echo(" - no external tool requirements detected")
142173
continue
143174
for tool in target["tools"]:
144175
plugins = ", ".join(target["plugins"].get(tool, []))
145-
click.echo(f" {tool}: {plugins}")
176+
state = ""
177+
if tool in set(target.get("missing_tools", [])):
178+
state = " [missing]"
179+
elif "present_tools" in target:
180+
state = " [present]"
181+
click.echo(f" {tool}{state}: {plugins}")
146182
click.echo(" preflight commands:")
147183
for command in target["commands"]:
148184
click.echo(f" {command}")
149185

150186

187+
def _format_csv(values: Iterable[str]) -> str:
188+
values = list(values)
189+
return ", ".join(values) if values else "none"
190+
191+
192+
def _echo_capability_install_event(event: Dict[str, Any]) -> None:
193+
kind = event.get("event")
194+
if kind == "job":
195+
click.echo(f"Job: {event['job']}")
196+
return
197+
target = event.get("target", "-")
198+
host = event.get("host", "-")
199+
os_family = event.get("os", {}).get("family", "unknown")
200+
if kind == "target-check":
201+
click.echo(f"[CHECK] {target} {host} os={os_family}: checking required tools")
202+
return
203+
if kind == "target-missing":
204+
missing_tools = event.get("missing_tools", [])
205+
packages = event.get("packages", [])
206+
unresolved = event.get("unresolved_tools", [])
207+
if missing_tools:
208+
click.echo(f"[MISSING] {target}: tools={_format_csv(missing_tools)}")
209+
else:
210+
click.echo(f"[OK] {target}: all required tools already present")
211+
if packages:
212+
click.echo(f"[PLAN] {target}: install packages={_format_csv(packages)}")
213+
if unresolved:
214+
click.echo(f"[WARN] {target}: unresolved tools={_format_csv(unresolved)}")
215+
return
216+
if kind == "target-install":
217+
click.echo(f"[INSTALL] {target}: packages={_format_csv(event.get('packages', []))}")
218+
return
219+
if kind == "target-done":
220+
status = "OK" if event.get("ok") else "FAILED"
221+
changed = "changed=true" if event.get("changed") else "changed=false"
222+
click.echo(f"[{status}] {target} {host} os={os_family} rc={event.get('rc', 0)} {changed}")
223+
224+
151225
def _echo_capability_install_payload(payload: Dict[str, Any], output_format: str) -> None:
152226
if output_format == "json":
153227
click.echo(json.dumps(payload, indent=2, sort_keys=True))
154228
return
155-
click.echo(f"Job: {payload['job']}")
229+
ok_targets = sum(1 for target in payload["targets"] if target["ok"])
230+
failed_targets = len(payload["targets"]) - ok_targets
231+
changed_targets = sum(1 for target in payload["targets"] if target["changed"])
232+
click.echo("Summary:")
233+
click.echo(f" targets: {len(payload['targets'])}")
234+
click.echo(f" ok: {ok_targets}")
235+
click.echo(f" failed: {failed_targets}")
236+
click.echo(f" changed: {changed_targets}")
156237
for target in payload["targets"]:
157-
status = "OK" if target["ok"] else "FAILED"
158-
click.echo(f"[{status}] {target['target']} {target['host']} os={target['os']['family']} rc={target['rc']}")
159-
if target["missing_tools"]:
160-
click.echo(" missing tools:")
161-
for tool in target["missing_tools"]:
162-
click.echo(f" {tool}")
163-
if target["packages"]:
164-
click.echo(" installed packages:")
165-
for package in target["packages"]:
166-
click.echo(f" {package}")
167-
if target["unresolved_tools"]:
168-
click.echo(" unresolved tools:")
169-
for tool in target["unresolved_tools"]:
170-
click.echo(f" {tool}")
238+
if target["stdout"]:
239+
click.echo(f" {target['target']} stdout:")
240+
for line in target["stdout"].splitlines():
241+
click.echo(f" {line}")
171242
if target["stderr"]:
172-
click.echo(" stderr:")
243+
click.echo(f" {target['target']} stderr:")
173244
for line in target["stderr"].splitlines():
174245
click.echo(f" {line}")
175246

@@ -1098,6 +1169,7 @@ def capability_requirements(
10981169
tags=_split_selectors(tags),
10991170
skip_tags=_split_selectors(skip_tags),
11001171
cli_vars=_parse_vars(cli_vars),
1172+
check_missing=output_format == "text",
11011173
)
11021174
except (AutomaxError, ValueError, RuntimeError) as exc:
11031175
raise click.ClickException(str(exc)) from exc
@@ -1147,6 +1219,7 @@ def capability_install(
11471219
skip_tags=_split_selectors(skip_tags),
11481220
cli_vars=_parse_vars(cli_vars),
11491221
sudo_password_env=sudo_password_env,
1222+
progress_callback=_echo_capability_install_event if output_format == "text" else None,
11501223
)
11511224
except (AutomaxError, ValueError, RuntimeError) as exc:
11521225
raise click.ClickException(str(exc)) from exc

src/automax/core/engine.py

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from threading import Lock
2121
import time
2222
import uuid
23-
from typing import Any, Dict, Iterable, List, Optional
23+
from typing import Any, Callable, Dict, Iterable, List, Optional
2424

2525
from automax.core.capabilities import collect_requirements, package_for_tool, plugin_os_mismatch
2626
from automax.core.inventory import Inventory, load_inventory_document
@@ -841,6 +841,7 @@ def capability_requirements_job(
841841
tags: Iterable[str] = (),
842842
skip_tags: Iterable[str] = (),
843843
cli_vars: Optional[Dict[str, Any]] = None,
844+
check_missing: bool = False,
844845
) -> Dict[str, Any]:
845846
"""Return job-scoped remote capability requirements from the selected plan."""
846847
resolved = self.resolve_job_context(
@@ -856,13 +857,17 @@ def capability_requirements_job(
856857
)
857858
os_by_target = self._detect_os_for_plan(resolved.plan, resolved.secrets)
858859
requirements = collect_requirements(self.iter_rendered_plan_items(resolved, dry_run=True), os_by_target)
860+
if check_missing:
861+
self._annotate_missing_capabilities(requirements, resolved.plan)
859862
return {
860863
"job": self._job_name(resolved.job),
861864
"mode": "capability-requirements",
862865
"targets": [requirements[name] for name in sorted(requirements)],
863866
"target_count": len(requirements),
864867
"tool_count": len({tool for item in requirements.values() for tool in item["tools"]}),
865868
"package_count": len({package for item in requirements.values() for package in item.get("packages", [])}),
869+
"missing_tool_count": len({tool for item in requirements.values() for tool in item.get("missing_tools", [])}),
870+
"missing_package_count": len({package for item in requirements.values() for package in item.get("missing_packages", [])}),
866871
}
867872

868873
def install_capability_requirements_job(
@@ -878,6 +883,7 @@ def install_capability_requirements_job(
878883
skip_tags: Iterable[str] = (),
879884
cli_vars: Optional[Dict[str, Any]] = None,
880885
sudo_password_env: str | None = None,
886+
progress_callback: Callable[[Dict[str, Any]], None] | None = None,
881887
) -> Dict[str, Any]:
882888
"""Install missing target packages required by the selected job plan."""
883889
resolved = self.resolve_job_context(
@@ -896,17 +902,30 @@ def install_capability_requirements_job(
896902
sudo_password = os.environ.get(sudo_password_env) if sudo_password_env else None
897903
targets = []
898904
ok = True
905+
emit = progress_callback or (lambda event: None)
906+
emit({"event": "job", "job": self._job_name(resolved.job)})
899907
for target_name in sorted(requirements):
900908
entry = requirements[target_name]
901909
target = next(item["target"] for item in resolved.plan if item["target"].name == target_name)
910+
emit({"event": "target-check", "target": target_name, "host": entry["host"], "os": entry["os"]})
902911
missing_tools = self._missing_tools(target, entry["tools"])
903-
packages = sorted({package for tool in missing_tools if (package := package_for_tool(tool, entry["os"]["family"]))})
904-
unresolved = sorted(tool for tool in missing_tools if package_for_tool(tool, entry["os"]["family"]) is None)
912+
packages = self._packages_for_tools(missing_tools, entry["os"]["family"])
913+
unresolved = self._unresolved_tools(missing_tools, entry["os"]["family"])
914+
emit({
915+
"event": "target-missing",
916+
"target": target_name,
917+
"host": entry["host"],
918+
"os": entry["os"],
919+
"missing_tools": missing_tools,
920+
"packages": packages,
921+
"unresolved_tools": unresolved,
922+
})
905923
rc = 0
906924
stdout = ""
907925
stderr = ""
908926
changed = False
909927
if packages:
928+
emit({"event": "target-install", "target": target_name, "host": entry["host"], "os": entry["os"], "packages": packages})
910929
rc, stdout, stderr = self._install_packages_for_os(
911930
target=target,
912931
os_family=entry["os"]["family"],
@@ -916,6 +935,17 @@ def install_capability_requirements_job(
916935
changed = rc == 0
917936
target_ok = rc == 0 and not unresolved
918937
ok = ok and target_ok
938+
emit({
939+
"event": "target-done",
940+
"target": target_name,
941+
"host": entry["host"],
942+
"os": entry["os"],
943+
"rc": rc,
944+
"ok": target_ok,
945+
"changed": changed,
946+
"packages": packages,
947+
"unresolved_tools": unresolved,
948+
})
919949
targets.append(
920950
{
921951
"target": target_name,
@@ -1307,6 +1337,26 @@ def _os_to_mapping(os_info: TargetOS) -> Dict[str, Any]:
13071337
"package_manager": os_info.package_manager,
13081338
}
13091339

1340+
def _plan_target_by_name(self, plan: Iterable[Dict[str, Any]], target_name: str) -> Target:
1341+
return next(item["target"] for item in plan if item["target"].name == target_name)
1342+
1343+
def _packages_for_tools(self, tools: Iterable[str], os_family: str) -> list[str]:
1344+
return sorted({package for tool in tools if (package := package_for_tool(tool, os_family))})
1345+
1346+
def _unresolved_tools(self, tools: Iterable[str], os_family: str) -> list[str]:
1347+
return sorted(tool for tool in tools if package_for_tool(tool, os_family) is None)
1348+
1349+
def _annotate_missing_capabilities(self, requirements: Dict[str, Dict[str, Any]], plan: Iterable[Dict[str, Any]]) -> None:
1350+
for target_name in sorted(requirements):
1351+
entry = requirements[target_name]
1352+
target = self._plan_target_by_name(plan, target_name)
1353+
missing_tools = self._missing_tools(target, entry["tools"])
1354+
missing_set = set(missing_tools)
1355+
entry["present_tools"] = sorted(tool for tool in entry["tools"] if tool not in missing_set)
1356+
entry["missing_tools"] = missing_tools
1357+
entry["missing_packages"] = self._packages_for_tools(missing_tools, entry["os"]["family"])
1358+
entry["unresolved_tools"] = self._unresolved_tools(missing_tools, entry["os"]["family"])
1359+
13101360
def _missing_tools(self, target: Target, tools: Iterable[str]) -> list[str]:
13111361
if not tools:
13121362
return []

0 commit comments

Comments
 (0)