Skip to content

Commit 8864050

Browse files
committed
add target OS info command
1 parent b4c98b7 commit 8864050

5 files changed

Lines changed: 230 additions & 1 deletion

File tree

docs/reference/cli.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,20 @@ automax runs show <run-id> --server web01
104104
automax runs show <run-id> --json
105105
```
106106

107+
108+
## OS information
109+
110+
Detect operating-system release facts for inventory targets before capability
111+
planning or dependency installation:
112+
113+
```bash
114+
automax os info --inventory inventory.yaml
115+
automax os info --inventory inventory.yaml --limit web --format=json
116+
```
117+
118+
The output includes `ID`, `ID_LIKE`, pretty release name, version, codename,
119+
normalized family (`debian`, `rhel` or `unknown`) and package manager.
120+
107121
## Inventory inspection
108122

109123
Show the inventory targets selected by a specific job:

src/automax/cli/cli.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,47 @@ def _echo_capability_install_payload(payload: Dict[str, Any], output_format: str
174174
click.echo(f" {line}")
175175

176176

177+
def _echo_os_info_payload(payload: Dict[str, Any], output_format: str) -> None:
178+
if output_format == "json":
179+
click.echo(json.dumps(payload, indent=2, sort_keys=True))
180+
return
181+
click.echo(f"Inventory: {payload['inventory']}")
182+
click.echo(f"Targets: {payload['target_count']}")
183+
for target in payload["targets"]:
184+
os_info = target["os"]
185+
pretty = os_info.get("pretty_name") or os_info.get("name") or os_info.get("id") or "unknown"
186+
version = os_info.get("version_id") or os_info.get("version") or "-"
187+
codename = os_info.get("version_codename") or "-"
188+
id_like = ",".join(os_info.get("id_like") or []) or "-"
189+
user = target.get("user") or "-"
190+
click.echo(
191+
f"Target {target['target']} {target['host']}:{target['port']} "
192+
f"user={user} os={pretty} family={os_info.get('family', 'unknown')} "
193+
f"id={os_info.get('id', 'unknown')} version={version} codename={codename} "
194+
f"id_like={id_like} pkg={os_info.get('package_manager', 'unknown')}"
195+
)
196+
197+
198+
def _os_info_payload(
199+
*,
200+
inventory_path: str,
201+
vars_path: str | None,
202+
secrets_path: str | None,
203+
cli_vars: tuple[str, ...],
204+
limit: tuple[str, ...],
205+
exclude: tuple[str, ...],
206+
plugin_path: tuple[str, ...],
207+
) -> Dict[str, Any]:
208+
return _engine(plugin_path).os_info_inventory(
209+
inventory_path=inventory_path,
210+
vars_path=vars_path,
211+
secrets_path=secrets_path,
212+
limit=_split_selectors(limit),
213+
exclude=_split_selectors(exclude),
214+
cli_vars=_parse_vars(cli_vars),
215+
)
216+
217+
177218
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
178219
@click.version_option(version=__version__, prog_name="Automax")
179220
def cli() -> None:
@@ -965,6 +1006,53 @@ def check_secrets(
9651006

9661007

9671008

1009+
@cli.group()
1010+
def os() -> None:
1011+
"""Inspect target operating-system facts."""
1012+
1013+
1014+
@os.command("info")
1015+
@click.option("--inventory", "inventory_path", required=True, type=click.Path(exists=True), help="External inventory YAML path.")
1016+
@click.option("--vars", "vars_path", type=click.Path(exists=True), help="External variables YAML path.")
1017+
@click.option("--secrets", "secrets_path", type=click.Path(exists=True), help="External secrets YAML path.")
1018+
@click.option("--var", "cli_vars", multiple=True, help="Override variable, format KEY=VALUE.")
1019+
@click.option("--limit", multiple=True, help="Limit targets. Accepts server, group or group:name.")
1020+
@click.option("--exclude", multiple=True, help="Exclude targets. Accepts server, group or group:name.")
1021+
@click.option("--plugin-path", multiple=True, help="External plugin file or directory.")
1022+
@click.option(
1023+
"--format",
1024+
"output_format",
1025+
type=click.Choice(["text", "json"]),
1026+
default="text",
1027+
show_default=True,
1028+
help="Output format.",
1029+
)
1030+
def os_info(
1031+
inventory_path: str,
1032+
vars_path: str | None,
1033+
secrets_path: str | None,
1034+
cli_vars: tuple[str, ...],
1035+
limit: tuple[str, ...],
1036+
exclude: tuple[str, ...],
1037+
plugin_path: tuple[str, ...],
1038+
output_format: str,
1039+
) -> None:
1040+
"""Detect OS release facts for selected inventory targets."""
1041+
try:
1042+
payload = _os_info_payload(
1043+
inventory_path=inventory_path,
1044+
vars_path=vars_path,
1045+
secrets_path=secrets_path,
1046+
cli_vars=cli_vars,
1047+
limit=limit,
1048+
exclude=exclude,
1049+
plugin_path=plugin_path,
1050+
)
1051+
except (AutomaxError, ValueError, RuntimeError) as exc:
1052+
raise click.ClickException(str(exc)) from exc
1053+
_echo_os_info_payload(payload, output_format)
1054+
1055+
9681056
@cli.group()
9691057
def capabilities() -> None:
9701058
"""Inspect job-scoped remote capability requirements."""

src/automax/core/engine.py

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -787,6 +787,48 @@ def manual_commands_job(
787787
"nodes": rows,
788788
}
789789

790+
def os_info_inventory(
791+
self,
792+
*,
793+
inventory_path: str,
794+
vars_path: str | None = None,
795+
secrets_path: str | None = None,
796+
limit: Iterable[str] = (),
797+
exclude: Iterable[str] = (),
798+
cli_vars: Optional[Dict[str, Any]] = None,
799+
) -> Dict[str, Any]:
800+
"""Detect and report operating-system facts for inventory targets."""
801+
vars_document = load_yaml_file(vars_path, required=False) if vars_path else {}
802+
secrets_document = load_yaml_file(secrets_path, required=False) if secrets_path else {}
803+
secrets = self.secret_manager.resolve_all(
804+
secrets_document,
805+
base_dir=self._path_parent(secrets_path),
806+
)
807+
variables = self._merge_variables(vars_document, cli_vars or {})
808+
context = {"vars": variables, "secrets": secrets}
809+
inventory_document = load_inventory_document(inventory_path, context)
810+
inventory = Inventory(inventory_document, context)
811+
targets = inventory.select("all", limit=list(limit), exclude=list(exclude))
812+
os_by_target = self._detect_os_for_targets(targets, secrets)
813+
rows = []
814+
for target in targets:
815+
os_info = os_by_target[target.name]
816+
rows.append(
817+
{
818+
"target": target.name,
819+
"host": target.host,
820+
"port": target.port,
821+
"user": target.user,
822+
"os": self._os_to_mapping(os_info),
823+
}
824+
)
825+
return {
826+
"mode": "os-info",
827+
"inventory": str(Path(inventory_path).expanduser()),
828+
"target_count": len(rows),
829+
"targets": rows,
830+
}
831+
790832
def capability_requirements_job(
791833
self,
792834
*,
@@ -1233,8 +1275,13 @@ def _plan_requires_capability_preflight(
12331275

12341276
def _detect_os_for_plan(self, plan: List[Dict[str, Any]], secrets: Dict[str, Any]) -> Dict[str, TargetOS]:
12351277
"""Detect target OS once per target before capability preflight or install."""
1278+
targets = {item["target"].name: item["target"] for item in plan}.values()
1279+
return self._detect_os_for_targets(targets, secrets)
1280+
1281+
def _detect_os_for_targets(self, targets: Iterable[Target], secrets: Dict[str, Any]) -> Dict[str, TargetOS]:
1282+
"""Detect operating-system facts once for each target."""
12361283
detected: Dict[str, TargetOS] = {}
1237-
for target in {item["target"].name: item["target"] for item in plan}.values():
1284+
for target in targets:
12381285
with self.ssh_manager.connect(target) as client:
12391286
_stdin, stdout, stderr = client.exec_command(DETECT_OS_COMMAND)
12401287
rc = stdout.channel.recv_exit_status()
@@ -1251,7 +1298,11 @@ def _os_to_mapping(os_info: TargetOS) -> Dict[str, Any]:
12511298
return {
12521299
"id": os_info.id,
12531300
"id_like": list(os_info.id_like),
1301+
"name": os_info.name,
1302+
"pretty_name": os_info.pretty_name,
1303+
"version": os_info.version,
12541304
"version_id": os_info.version_id,
1305+
"version_codename": os_info.version_codename,
12551306
"family": os_info.family,
12561307
"package_manager": os_info.package_manager,
12571308
}

src/automax/core/os_detect.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@ class TargetOS:
1414

1515
id: str = "unknown"
1616
id_like: tuple[str, ...] = ()
17+
name: str = ""
18+
pretty_name: str = ""
19+
version: str = ""
1720
version_id: str = ""
21+
version_codename: str = ""
1822
family: str = "unknown"
1923
package_manager: str = "unknown"
2024

@@ -52,7 +56,11 @@ def parse_os_release(text: str) -> TargetOS:
5256
return TargetOS(
5357
id=os_id,
5458
id_like=id_like,
59+
name=values.get("NAME", ""),
60+
pretty_name=values.get("PRETTY_NAME", ""),
61+
version=values.get("VERSION", ""),
5562
version_id=values.get("VERSION_ID", ""),
63+
version_codename=values.get("VERSION_CODENAME", values.get("UBUNTU_CODENAME", "")),
5664
family=family,
5765
package_manager=package_manager,
5866
)

tests/test_next_engine.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4656,3 +4656,71 @@ def fake_install(*, target, os_family, packages, sudo_password):
46564656
assert installs == [("node", "debian", ["acl", "zip"], None)]
46574657
assert payload["targets"][0]["missing_tools"] == ["setfacl", "zip"]
46584658
assert payload["targets"][0]["packages"] == ["acl", "zip"]
4659+
4660+
4661+
def test_os_info_inventory_reports_release_details(tmp_path: Path, monkeypatch):
4662+
from automax.core.os_detect import TargetOS
4663+
4664+
inventory = write(
4665+
tmp_path / "inventory.yaml",
4666+
"""
4667+
servers:
4668+
node:
4669+
host: 127.0.0.1
4670+
ssh:
4671+
user: ubuntu
4672+
""",
4673+
)
4674+
engine = AutomaxEngine()
4675+
monkeypatch.setattr(
4676+
engine,
4677+
"_detect_os_for_targets",
4678+
lambda targets, secrets: {
4679+
"node": TargetOS(
4680+
id="ubuntu",
4681+
id_like=("debian",),
4682+
name="Ubuntu",
4683+
pretty_name="Ubuntu 24.04.2 LTS",
4684+
version="24.04.2 LTS (Noble Numbat)",
4685+
version_id="24.04",
4686+
version_codename="noble",
4687+
family="debian",
4688+
package_manager="apt",
4689+
)
4690+
},
4691+
)
4692+
4693+
payload = engine.os_info_inventory(inventory_path=str(inventory))
4694+
4695+
assert payload["mode"] == "os-info"
4696+
assert payload["target_count"] == 1
4697+
target = payload["targets"][0]
4698+
assert target["target"] == "node"
4699+
assert target["user"] == "ubuntu"
4700+
assert target["os"]["pretty_name"] == "Ubuntu 24.04.2 LTS"
4701+
assert target["os"]["version_codename"] == "noble"
4702+
assert target["os"]["family"] == "debian"
4703+
4704+
4705+
def test_os_info_cli_json_output(tmp_path: Path, monkeypatch):
4706+
from automax.core.os_detect import TargetOS
4707+
4708+
inventory = write(tmp_path / "inventory.yaml", "servers:\n node:\n host: 127.0.0.1\n")
4709+
monkeypatch.setattr(
4710+
AutomaxEngine,
4711+
"_detect_os_for_targets",
4712+
lambda self, targets, secrets: {
4713+
"node": TargetOS(id="debian", id_like=(), pretty_name="Debian GNU/Linux 12", version_id="12", family="debian", package_manager="apt")
4714+
},
4715+
)
4716+
4717+
result = CliRunner().invoke(
4718+
cli,
4719+
["os", "info", "--inventory", str(inventory), "--format", "json"],
4720+
)
4721+
4722+
assert result.exit_code == 0, result.output
4723+
payload = json.loads(result.output)
4724+
assert payload["mode"] == "os-info"
4725+
assert payload["targets"][0]["os"]["id"] == "debian"
4726+
assert payload["targets"][0]["os"]["pretty_name"] == "Debian GNU/Linux 12"

0 commit comments

Comments
 (0)