infra-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- infra_mcp-0.1.0/.github/workflows/publish.yml +32 -0
- infra_mcp-0.1.0/.gitignore +24 -0
- infra_mcp-0.1.0/PKG-INFO +107 -0
- infra_mcp-0.1.0/README.md +85 -0
- infra_mcp-0.1.0/infra-mcp.yaml.example +45 -0
- infra_mcp-0.1.0/pyproject.toml +43 -0
- infra_mcp-0.1.0/src/infra_mcp/__init__.py +3 -0
- infra_mcp-0.1.0/src/infra_mcp/__main__.py +6 -0
- infra_mcp-0.1.0/src/infra_mcp/audit.py +28 -0
- infra_mcp-0.1.0/src/infra_mcp/cli.py +126 -0
- infra_mcp-0.1.0/src/infra_mcp/config.py +153 -0
- infra_mcp-0.1.0/src/infra_mcp/db.py +181 -0
- infra_mcp-0.1.0/src/infra_mcp/errors.py +25 -0
- infra_mcp-0.1.0/src/infra_mcp/runtime.py +32 -0
- infra_mcp-0.1.0/src/infra_mcp/server.py +29 -0
- infra_mcp-0.1.0/src/infra_mcp/setup.py +346 -0
- infra_mcp-0.1.0/src/infra_mcp/ssh.py +123 -0
- infra_mcp-0.1.0/src/infra_mcp/tools/__init__.py +1 -0
- infra_mcp-0.1.0/src/infra_mcp/tools/db_tools.py +54 -0
- infra_mcp-0.1.0/src/infra_mcp/tools/meta_tools.py +62 -0
- infra_mcp-0.1.0/src/infra_mcp/tools/ssh_tools.py +119 -0
- infra_mcp-0.1.0/tests/test_bounding.py +25 -0
- infra_mcp-0.1.0/tests/test_config_db_ssh.py +49 -0
- infra_mcp-0.1.0/tests/test_db_session.py +160 -0
- infra_mcp-0.1.0/tests/test_path_allowlist.py +82 -0
- infra_mcp-0.1.0/tests/test_sql_guard.py +47 -0
- infra_mcp-0.1.0/tests/test_ssh_allowlist.py +34 -0
- infra_mcp-0.1.0/tests/test_strip_ansi.py +19 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
build-and-publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write # required for trusted publishing (OIDC)
|
|
13
|
+
contents: read # required for actions/checkout on private repos
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.11"
|
|
20
|
+
|
|
21
|
+
- name: Build distributions
|
|
22
|
+
run: |
|
|
23
|
+
python -m pip install --upgrade build
|
|
24
|
+
python -m build
|
|
25
|
+
|
|
26
|
+
- name: Check distributions
|
|
27
|
+
run: |
|
|
28
|
+
python -m pip install --upgrade twine
|
|
29
|
+
twine check dist/*
|
|
30
|
+
|
|
31
|
+
- name: Publish to PyPI
|
|
32
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
docs/
|
|
2
|
+
specs/
|
|
3
|
+
CLAUDE.md
|
|
4
|
+
SKILL.md
|
|
5
|
+
__pycache__/
|
|
6
|
+
*.pyc
|
|
7
|
+
.venv/
|
|
8
|
+
venv/
|
|
9
|
+
dist/
|
|
10
|
+
build/
|
|
11
|
+
*.egg-info/
|
|
12
|
+
.pytest_cache/
|
|
13
|
+
.ruff_cache/
|
|
14
|
+
*.log
|
|
15
|
+
.env*
|
|
16
|
+
.DS_Store
|
|
17
|
+
Thumbs.db
|
|
18
|
+
infra-mcp.yaml
|
|
19
|
+
infra-mcp.discovered.yaml
|
|
20
|
+
!infra-mcp.yaml.example
|
|
21
|
+
.claude/
|
|
22
|
+
.idea/
|
|
23
|
+
.specify/
|
|
24
|
+
_demo.py
|
infra_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: infra-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Local stdio MCP server for read-only diagnosis of on-prem Linux VMs and PostgreSQL databases
|
|
5
|
+
Project-URL: Homepage, https://github.com/esp4ce/infra-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/esp4ce/infra-mcp
|
|
7
|
+
Author: esp4ce
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Requires-Dist: keyring>=24.0
|
|
11
|
+
Requires-Dist: mcp[cli]<2.0,>=1.28
|
|
12
|
+
Requires-Dist: paramiko<4,>=3.0
|
|
13
|
+
Requires-Dist: psycopg2-binary>=2.9
|
|
14
|
+
Requires-Dist: pydantic>=2.11
|
|
15
|
+
Requires-Dist: pyyaml>=6.0
|
|
16
|
+
Requires-Dist: sshtunnel>=0.4
|
|
17
|
+
Requires-Dist: typer>=0.12
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
20
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# infra-mcp
|
|
24
|
+
|
|
25
|
+
A local **stdio MCP server** that gives an AI agent read-only visibility into
|
|
26
|
+
on-premise Linux VMs (SSH + journald) and PostgreSQL databases. The agent can
|
|
27
|
+
diagnose service failures, retrieve bounded logs, and check DB health without any
|
|
28
|
+
user terminal interaction.
|
|
29
|
+
|
|
30
|
+
v0.1 is deliberately **read-only**. Every remote operation is gated by:
|
|
31
|
+
|
|
32
|
+
- an **SSH command/service allowlist** (checked before any network call),
|
|
33
|
+
- a **SQL `SELECT` guard** + `READ ONLY` transaction,
|
|
34
|
+
- a **directory allowlist** for log-file access (with `..` traversal blocked),
|
|
35
|
+
|
|
36
|
+
and every executed remote command is written to an append-only **audit log**.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
uv tool install infra-mcp
|
|
42
|
+
# or from source:
|
|
43
|
+
uv tool install -e /path/to/infra-probe
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
(`pip install infra-mcp` also works.)
|
|
47
|
+
|
|
48
|
+
## Configure
|
|
49
|
+
|
|
50
|
+
Copy [`infra-mcp.yaml.example`](infra-mcp.yaml.example) to
|
|
51
|
+
`~/.infra-mcp/infra-mcp.yaml` and edit it. Override the path with `--config` or
|
|
52
|
+
the `INFRA_MCP_CONFIG` environment variable.
|
|
53
|
+
|
|
54
|
+
Generate a starter config from your `~/.ssh/config`:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
infra-mcp generate-config -o ~/.infra-mcp/infra-mcp.yaml
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Create the read-only PostgreSQL role(s) (admin password is prompted, never stored):
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
infra-mcp setup
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Check VM reachability:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
infra-mcp test
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Run
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
infra-mcp run
|
|
76
|
+
# or: python -m infra_mcp run
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Register it with your MCP client (Claude Code, Cursor, …) as a **stdio** server
|
|
80
|
+
whose command is `infra-mcp run`.
|
|
81
|
+
|
|
82
|
+
## Tools
|
|
83
|
+
|
|
84
|
+
| Tool | Purpose |
|
|
85
|
+
|------|---------|
|
|
86
|
+
| `list_vms` | All VMs with reachability + watched services (no IPs) |
|
|
87
|
+
| `get_infra_overview` | Service states + DB health for one VM in a single call |
|
|
88
|
+
| `get_service_status` | systemd state, uptime, last 5 log lines |
|
|
89
|
+
| `get_service_logs` | Bounded journald logs, filtered by severity |
|
|
90
|
+
| `get_log_file` | Last N lines of an allowed log file, optional grep |
|
|
91
|
+
| `get_db_status` | Connection counts, waiting locks, long-running query count |
|
|
92
|
+
| `query_db` | Bounded caller-supplied `SELECT` |
|
|
93
|
+
| `get_audit_log` | Recent entries from the local audit log |
|
|
94
|
+
|
|
95
|
+
All output is bounded at the source (hard cap 200 log lines, 100 DB rows) and
|
|
96
|
+
returned as plain text / compact TSV.
|
|
97
|
+
|
|
98
|
+
## Development
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
uv pip install -e ".[dev]"
|
|
102
|
+
pytest
|
|
103
|
+
ruff check .
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Tests cover output bounding, the SQL guard, and the path/command allowlists — no
|
|
107
|
+
live VM or database required.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# infra-mcp
|
|
2
|
+
|
|
3
|
+
A local **stdio MCP server** that gives an AI agent read-only visibility into
|
|
4
|
+
on-premise Linux VMs (SSH + journald) and PostgreSQL databases. The agent can
|
|
5
|
+
diagnose service failures, retrieve bounded logs, and check DB health without any
|
|
6
|
+
user terminal interaction.
|
|
7
|
+
|
|
8
|
+
v0.1 is deliberately **read-only**. Every remote operation is gated by:
|
|
9
|
+
|
|
10
|
+
- an **SSH command/service allowlist** (checked before any network call),
|
|
11
|
+
- a **SQL `SELECT` guard** + `READ ONLY` transaction,
|
|
12
|
+
- a **directory allowlist** for log-file access (with `..` traversal blocked),
|
|
13
|
+
|
|
14
|
+
and every executed remote command is written to an append-only **audit log**.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv tool install infra-mcp
|
|
20
|
+
# or from source:
|
|
21
|
+
uv tool install -e /path/to/infra-probe
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
(`pip install infra-mcp` also works.)
|
|
25
|
+
|
|
26
|
+
## Configure
|
|
27
|
+
|
|
28
|
+
Copy [`infra-mcp.yaml.example`](infra-mcp.yaml.example) to
|
|
29
|
+
`~/.infra-mcp/infra-mcp.yaml` and edit it. Override the path with `--config` or
|
|
30
|
+
the `INFRA_MCP_CONFIG` environment variable.
|
|
31
|
+
|
|
32
|
+
Generate a starter config from your `~/.ssh/config`:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
infra-mcp generate-config -o ~/.infra-mcp/infra-mcp.yaml
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Create the read-only PostgreSQL role(s) (admin password is prompted, never stored):
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
infra-mcp setup
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Check VM reachability:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
infra-mcp test
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Run
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
infra-mcp run
|
|
54
|
+
# or: python -m infra_mcp run
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Register it with your MCP client (Claude Code, Cursor, …) as a **stdio** server
|
|
58
|
+
whose command is `infra-mcp run`.
|
|
59
|
+
|
|
60
|
+
## Tools
|
|
61
|
+
|
|
62
|
+
| Tool | Purpose |
|
|
63
|
+
|------|---------|
|
|
64
|
+
| `list_vms` | All VMs with reachability + watched services (no IPs) |
|
|
65
|
+
| `get_infra_overview` | Service states + DB health for one VM in a single call |
|
|
66
|
+
| `get_service_status` | systemd state, uptime, last 5 log lines |
|
|
67
|
+
| `get_service_logs` | Bounded journald logs, filtered by severity |
|
|
68
|
+
| `get_log_file` | Last N lines of an allowed log file, optional grep |
|
|
69
|
+
| `get_db_status` | Connection counts, waiting locks, long-running query count |
|
|
70
|
+
| `query_db` | Bounded caller-supplied `SELECT` |
|
|
71
|
+
| `get_audit_log` | Recent entries from the local audit log |
|
|
72
|
+
|
|
73
|
+
All output is bounded at the source (hard cap 200 log lines, 100 DB rows) and
|
|
74
|
+
returned as plain text / compact TSV.
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
uv pip install -e ".[dev]"
|
|
80
|
+
pytest
|
|
81
|
+
ruff check .
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
Tests cover output bounding, the SQL guard, and the path/command allowlists — no
|
|
85
|
+
live VM or database required.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# infra-mcp configuration — single source of truth (plain text).
|
|
2
|
+
# Copy to ~/.infra-mcp/infra-mcp.yaml and edit. Manage file permissions yourself
|
|
3
|
+
# (e.g. chmod 600). Override the location with the INFRA_MCP_CONFIG env var or
|
|
4
|
+
# the --config / -c CLI flag.
|
|
5
|
+
|
|
6
|
+
# Local audit log location (append-only JSONL). Optional.
|
|
7
|
+
audit_log_path: ~/.infra-mcp/audit.jsonl
|
|
8
|
+
|
|
9
|
+
# Application log level (NOT the audit log). Optional. Default: WARNING.
|
|
10
|
+
log_level: WARNING
|
|
11
|
+
|
|
12
|
+
# At least one VM is required.
|
|
13
|
+
vms:
|
|
14
|
+
- name: vm-med-files # logical name, lowercase-kebab, starts with a letter
|
|
15
|
+
host: 192.168.1.10 # SSH hostname or IP (never exposed to the agent)
|
|
16
|
+
user: deploy # SSH username
|
|
17
|
+
# SSH auth: provide key_path OR password (at least one).
|
|
18
|
+
key_path: ~/.ssh/id_rsa # path to SSH private key
|
|
19
|
+
# password: "secret" # plain-text SSH password (alternative to key_path)
|
|
20
|
+
# Per-VM known_hosts override. Optional; falls back to ~/.ssh/known_hosts.
|
|
21
|
+
known_hosts_file: ~/.ssh/known_hosts
|
|
22
|
+
|
|
23
|
+
# Watched systemd units. This list is the ALLOWLIST for service tool calls —
|
|
24
|
+
# a service not listed here is rejected before any SSH connection is made.
|
|
25
|
+
services:
|
|
26
|
+
- karaf
|
|
27
|
+
- haproxy
|
|
28
|
+
|
|
29
|
+
# Allowed directories for get_log_file. Must be ABSOLUTE paths. A requested
|
|
30
|
+
# path (after `..` normalization) must resolve inside one of these.
|
|
31
|
+
log_dirs:
|
|
32
|
+
- /var/log/karaf
|
|
33
|
+
- /var/log/haproxy
|
|
34
|
+
|
|
35
|
+
# PostgreSQL databases on this VM (connection is local to the VM).
|
|
36
|
+
databases:
|
|
37
|
+
- name: prod-db # logical name, globally unique across all VMs
|
|
38
|
+
db_name: medfiles # actual PostgreSQL database name
|
|
39
|
+
user: infra_readonly # read-only role (created by `infra-mcp setup`)
|
|
40
|
+
# Read-only role password. Use the sentinel "keyring" to load it from the
|
|
41
|
+
# system keychain instead (stored under service "infra-mcp", key = name).
|
|
42
|
+
password: "keyring"
|
|
43
|
+
host: localhost # optional, default localhost
|
|
44
|
+
port: 5432 # optional, default 5432
|
|
45
|
+
slow_query_ms: 1000 # optional, default 1000; threshold for "long-running"
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "infra-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Local stdio MCP server for read-only diagnosis of on-prem Linux VMs and PostgreSQL databases"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "esp4ce" }]
|
|
13
|
+
dependencies = [
|
|
14
|
+
"mcp[cli]>=1.28,<2.0",
|
|
15
|
+
"paramiko>=3.0,<4", # sshtunnel 0.4 (unmaintained) references paramiko.DSSKey, removed in paramiko 4
|
|
16
|
+
"psycopg2-binary>=2.9",
|
|
17
|
+
"sshtunnel>=0.4",
|
|
18
|
+
"pydantic>=2.11",
|
|
19
|
+
"pyyaml>=6.0",
|
|
20
|
+
"typer>=0.12",
|
|
21
|
+
"keyring>=24.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://github.com/esp4ce/infra-mcp"
|
|
26
|
+
Repository = "https://github.com/esp4ce/infra-mcp"
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
dev = ["pytest>=8.0", "ruff>=0.6"]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
infra-mcp = "infra_mcp.cli:app"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/infra_mcp"]
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 100
|
|
39
|
+
target-version = "py311"
|
|
40
|
+
|
|
41
|
+
[tool.pytest.ini_options]
|
|
42
|
+
pythonpath = ["src"]
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Append-only JSONL audit writer. One line per remote command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _now_iso() -> str:
|
|
11
|
+
"""ISO-8601 UTC timestamp, e.g. 2026-06-17T14:22:01Z."""
|
|
12
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log_command(audit_log_path: Path, target: str, cmd: str, exit_code: int) -> None:
|
|
16
|
+
"""Append one audit entry. Creates the parent directory if needed."""
|
|
17
|
+
audit_log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
entry = {"ts": _now_iso(), "target": target, "cmd": cmd, "exit_code": exit_code}
|
|
19
|
+
with audit_log_path.open("a", encoding="utf-8") as f:
|
|
20
|
+
f.write(json.dumps(entry, separators=(",", ":")) + "\n")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def read_tail(audit_log_path: Path, lines: int) -> list[str]:
|
|
24
|
+
"""Return the last `lines` audit entries, oldest first. Empty if no log yet."""
|
|
25
|
+
if not audit_log_path.exists():
|
|
26
|
+
return []
|
|
27
|
+
all_lines = audit_log_path.read_text(encoding="utf-8").splitlines()
|
|
28
|
+
return [ln for ln in all_lines if ln.strip()][-lines:]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Typer CLI: run / setup / generate-config / test subcommands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from infra_mcp.config import load_config
|
|
13
|
+
from infra_mcp.errors import InfraMcpError
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(add_completion=False, help="Read-only infra diagnosis MCP server.")
|
|
16
|
+
|
|
17
|
+
_ConfigOpt = typer.Option(None, "--config", "-c", help="Path to infra-mcp.yaml")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def run(config: Optional[Path] = _ConfigOpt) -> None:
|
|
22
|
+
"""Start the stdio MCP server."""
|
|
23
|
+
from infra_mcp import server
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
server.init_config(config)
|
|
27
|
+
except InfraMcpError as e:
|
|
28
|
+
typer.echo(f"ERROR: {e}", err=True)
|
|
29
|
+
raise typer.Exit(1) from e
|
|
30
|
+
server.mcp.run(transport="stdio")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@app.command()
|
|
34
|
+
def setup(config: Optional[Path] = _ConfigOpt) -> None:
|
|
35
|
+
"""Scan VMs, detect PostgreSQL, and create read-only roles. Idempotent."""
|
|
36
|
+
from infra_mcp import setup as setup_mod
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
cfg = load_config(config)
|
|
40
|
+
except InfraMcpError as e:
|
|
41
|
+
typer.echo(f"ERROR: {e}", err=True)
|
|
42
|
+
raise typer.Exit(1) from e
|
|
43
|
+
results = setup_mod.run_setup(cfg)
|
|
44
|
+
for name, ok, msg in results:
|
|
45
|
+
mark = "OK " if ok else "FAIL"
|
|
46
|
+
typer.echo(f"[{mark}] {name}: {msg}")
|
|
47
|
+
if any(not ok for _, ok, _ in results):
|
|
48
|
+
raise typer.Exit(1)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@app.command(name="generate-config")
|
|
52
|
+
def generate_config(
|
|
53
|
+
output: Optional[Path] = typer.Option(
|
|
54
|
+
None, "--output", "-o", help="Write to this path instead of stdout"
|
|
55
|
+
),
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Discover VMs from ~/.ssh/config and write a starter infra-mcp.yaml."""
|
|
58
|
+
from infra_mcp import setup as setup_mod
|
|
59
|
+
|
|
60
|
+
yaml_text = setup_mod.generate_config()
|
|
61
|
+
if output is None:
|
|
62
|
+
typer.echo(yaml_text)
|
|
63
|
+
else:
|
|
64
|
+
output.expanduser().parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
output.expanduser().write_text(yaml_text, encoding="utf-8")
|
|
66
|
+
typer.echo(f"Wrote {output}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@app.command()
|
|
70
|
+
def discover(
|
|
71
|
+
config: Optional[Path] = _ConfigOpt,
|
|
72
|
+
output: Optional[Path] = typer.Option(
|
|
73
|
+
None, "--output", "-o", help="Write enriched YAML here (default: stdout)"
|
|
74
|
+
),
|
|
75
|
+
in_place: bool = typer.Option(
|
|
76
|
+
False, "--in-place", "-i", help="Overwrite the loaded config file in place"
|
|
77
|
+
),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Connect to each configured VM and refresh services / log_dirs / databases."""
|
|
80
|
+
from infra_mcp import setup as setup_mod
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
cfg = load_config(config)
|
|
84
|
+
except InfraMcpError as e:
|
|
85
|
+
typer.echo(f"ERROR: {e}", err=True)
|
|
86
|
+
raise typer.Exit(1) from e
|
|
87
|
+
yaml_text = setup_mod.discover_config(cfg)
|
|
88
|
+
target = config if (in_place and config) else output
|
|
89
|
+
if target is None:
|
|
90
|
+
typer.echo(yaml_text)
|
|
91
|
+
else:
|
|
92
|
+
target.expanduser().parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
target.expanduser().write_text(yaml_text, encoding="utf-8")
|
|
94
|
+
typer.echo(f"Wrote {target}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def test(config: Optional[Path] = _ConfigOpt) -> None:
|
|
99
|
+
"""Attempt an SSH reachability check against each configured VM."""
|
|
100
|
+
from infra_mcp import ssh
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
cfg = load_config(config)
|
|
104
|
+
except InfraMcpError as e:
|
|
105
|
+
typer.echo(f"ERROR: {e}", err=True)
|
|
106
|
+
raise typer.Exit(1) from e
|
|
107
|
+
any_unreachable = False
|
|
108
|
+
for vm in cfg.vms:
|
|
109
|
+
start = time.monotonic()
|
|
110
|
+
reachable = ssh.is_reachable(vm)
|
|
111
|
+
latency_ms = int((time.monotonic() - start) * 1000)
|
|
112
|
+
if reachable:
|
|
113
|
+
typer.echo(f"[OK ] {vm.name}: reachable ({latency_ms} ms)")
|
|
114
|
+
else:
|
|
115
|
+
any_unreachable = True
|
|
116
|
+
typer.echo(f"[FAIL] {vm.name}: unreachable")
|
|
117
|
+
if any_unreachable:
|
|
118
|
+
raise typer.Exit(1)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def main() -> None:
|
|
122
|
+
app()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
if __name__ == "__main__":
|
|
126
|
+
sys.exit(app())
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Pydantic config models for infra-mcp.yaml + fail-fast load_config()."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, Field, ValidationError, field_validator, model_validator
|
|
10
|
+
|
|
11
|
+
from infra_mcp.errors import ConfigError
|
|
12
|
+
|
|
13
|
+
_NAME_PATTERN = r"^[a-z][a-z0-9-]*$"
|
|
14
|
+
_KEYRING_SENTINEL = "keyring"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class DatabaseConfig(BaseModel):
|
|
18
|
+
"""One PostgreSQL database, embedded under its VM."""
|
|
19
|
+
|
|
20
|
+
name: str = Field(pattern=_NAME_PATTERN)
|
|
21
|
+
db_name: str
|
|
22
|
+
user: str
|
|
23
|
+
password: str
|
|
24
|
+
host: str = "localhost"
|
|
25
|
+
port: int = 5432
|
|
26
|
+
slow_query_ms: int = 1000
|
|
27
|
+
|
|
28
|
+
# SSH coordinates of the parent VM, stamped at load time by VMConfig so DB
|
|
29
|
+
# tools can open an ephemeral tunnel without a separate persistent connection.
|
|
30
|
+
ssh_host: str | None = None
|
|
31
|
+
ssh_user: str | None = None
|
|
32
|
+
ssh_password: str | None = None
|
|
33
|
+
ssh_key_path: Path | None = None
|
|
34
|
+
ssh_known_hosts_file: Path | None = None
|
|
35
|
+
|
|
36
|
+
@field_validator("slow_query_ms")
|
|
37
|
+
@classmethod
|
|
38
|
+
def _positive_threshold(cls, v: int) -> int:
|
|
39
|
+
if v <= 0:
|
|
40
|
+
raise ValueError("slow_query_ms must be > 0")
|
|
41
|
+
return v
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class VMConfig(BaseModel):
|
|
45
|
+
"""One logical VM entry."""
|
|
46
|
+
|
|
47
|
+
name: str = Field(pattern=_NAME_PATTERN)
|
|
48
|
+
host: str
|
|
49
|
+
user: str
|
|
50
|
+
key_path: Path | None = None
|
|
51
|
+
password: str | None = None
|
|
52
|
+
known_hosts_file: Path | None = None
|
|
53
|
+
|
|
54
|
+
@model_validator(mode="after")
|
|
55
|
+
def _require_auth(self) -> "VMConfig":
|
|
56
|
+
if self.key_path is None and self.password is None:
|
|
57
|
+
raise ValueError(
|
|
58
|
+
f"VM {self.name}: provide key_path or password for SSH auth"
|
|
59
|
+
)
|
|
60
|
+
return self
|
|
61
|
+
services: list[str] = Field(default_factory=list)
|
|
62
|
+
# Remote (Linux) directories — validated as POSIX absolute paths, NOT local
|
|
63
|
+
# OS paths, so they work when the server runs on Windows/macOS.
|
|
64
|
+
log_dirs: list[str] = Field(default_factory=list)
|
|
65
|
+
databases: list[DatabaseConfig] = Field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
@model_validator(mode="after")
|
|
68
|
+
def _stamp_db_ssh(self) -> "VMConfig":
|
|
69
|
+
for db in self.databases:
|
|
70
|
+
db.ssh_host = self.host
|
|
71
|
+
db.ssh_user = self.user
|
|
72
|
+
db.ssh_password = self.password
|
|
73
|
+
db.ssh_key_path = self.key_path
|
|
74
|
+
db.ssh_known_hosts_file = self.known_hosts_file
|
|
75
|
+
return self
|
|
76
|
+
|
|
77
|
+
@field_validator("log_dirs")
|
|
78
|
+
@classmethod
|
|
79
|
+
def _abs_log_dirs(cls, v: list[str]) -> list[str]:
|
|
80
|
+
for d in v:
|
|
81
|
+
if not d.startswith("/"):
|
|
82
|
+
raise ValueError(f"log_dirs entry must be an absolute path: {d}")
|
|
83
|
+
return v
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class InfraMcpConfig(BaseModel):
|
|
87
|
+
"""Top-level config file model."""
|
|
88
|
+
|
|
89
|
+
vms: list[VMConfig] = Field(min_length=1)
|
|
90
|
+
audit_log_path: Path = Path("~/.infra-mcp/audit.jsonl").expanduser()
|
|
91
|
+
log_level: str = "WARNING"
|
|
92
|
+
|
|
93
|
+
@model_validator(mode="after")
|
|
94
|
+
def _unique_db_names(self) -> InfraMcpConfig:
|
|
95
|
+
seen: set[str] = set()
|
|
96
|
+
for vm in self.vms:
|
|
97
|
+
for db in vm.databases:
|
|
98
|
+
if db.name in seen:
|
|
99
|
+
raise ValueError(f"duplicate database name across VMs: {db.name}")
|
|
100
|
+
seen.add(db.name)
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def find_vm(self, name: str) -> VMConfig | None:
|
|
104
|
+
return next((vm for vm in self.vms if vm.name == name), None)
|
|
105
|
+
|
|
106
|
+
def find_db(self, name: str) -> tuple[VMConfig, DatabaseConfig] | None:
|
|
107
|
+
for vm in self.vms:
|
|
108
|
+
for db in vm.databases:
|
|
109
|
+
if db.name == name:
|
|
110
|
+
return vm, db
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_keyring_passwords(config: InfraMcpConfig) -> None:
|
|
115
|
+
"""Replace the `keyring` sentinel with the credential from the system keychain."""
|
|
116
|
+
import keyring
|
|
117
|
+
|
|
118
|
+
for vm in config.vms:
|
|
119
|
+
for db in vm.databases:
|
|
120
|
+
if db.password == _KEYRING_SENTINEL:
|
|
121
|
+
secret = keyring.get_password("infra-mcp", db.name)
|
|
122
|
+
if secret is None:
|
|
123
|
+
raise ConfigError(
|
|
124
|
+
f"database {db.name} uses keyring but no credential found "
|
|
125
|
+
f"under infra-mcp:{db.name}"
|
|
126
|
+
)
|
|
127
|
+
db.password = secret
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def default_config_path() -> Path:
|
|
131
|
+
"""Resolve config path from INFRA_MCP_CONFIG or the default location."""
|
|
132
|
+
env = os.environ.get("INFRA_MCP_CONFIG")
|
|
133
|
+
if env:
|
|
134
|
+
return Path(env).expanduser()
|
|
135
|
+
return Path("~/.infra-mcp/infra-mcp.yaml").expanduser()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def load_config(path: Path | None = None) -> InfraMcpConfig:
|
|
139
|
+
"""Load and validate config. Raises ConfigError on any failure (fail fast)."""
|
|
140
|
+
cfg_path = Path(path) if path is not None else default_config_path()
|
|
141
|
+
if not cfg_path.exists():
|
|
142
|
+
raise ConfigError(f"config file not found: {cfg_path}")
|
|
143
|
+
try:
|
|
144
|
+
raw = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
|
|
145
|
+
except yaml.YAMLError as e:
|
|
146
|
+
raise ConfigError(f"invalid YAML in {cfg_path}: {e}") from e
|
|
147
|
+
try:
|
|
148
|
+
config = InfraMcpConfig.model_validate(raw)
|
|
149
|
+
except ValidationError as e:
|
|
150
|
+
raise ConfigError(f"config validation failed: {e}") from e
|
|
151
|
+
config.audit_log_path = config.audit_log_path.expanduser()
|
|
152
|
+
_resolve_keyring_passwords(config)
|
|
153
|
+
return config
|