terraui 0.1.0__py3-none-any.whl
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.
- terraui/__init__.py +3 -0
- terraui/ai/__init__.py +0 -0
- terraui/ai/chat.py +98 -0
- terraui/cli.py +242 -0
- terraui/clouds/__init__.py +0 -0
- terraui/clouds/auth.py +76 -0
- terraui/clouds/preflight.py +192 -0
- terraui/demo.py +106 -0
- terraui/discovery/__init__.py +1 -0
- terraui/discovery/hcl.py +106 -0
- terraui/discovery/scanner.py +115 -0
- terraui/execution/__init__.py +0 -0
- terraui/execution/command.py +51 -0
- terraui/execution/executor.py +176 -0
- terraui/models.py +116 -0
- terraui/server/__init__.py +1 -0
- terraui/server/app.py +364 -0
- terraui/shell/__init__.py +0 -0
- terraui/shell/pty.py +113 -0
- terraui/state/__init__.py +0 -0
- terraui/state/inspect.py +53 -0
- terraui/store/__init__.py +1 -0
- terraui/store/db.py +85 -0
- terraui/web/index.html +829 -0
- terraui-0.1.0.dist-info/METADATA +156 -0
- terraui-0.1.0.dist-info/RECORD +29 -0
- terraui-0.1.0.dist-info/WHEEL +4 -0
- terraui-0.1.0.dist-info/entry_points.txt +2 -0
- terraui-0.1.0.dist-info/licenses/LICENSE +21 -0
terraui/__init__.py
ADDED
terraui/ai/__init__.py
ADDED
|
File without changes
|
terraui/ai/chat.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""AI assistant proxy (BACKEND_SPEC §12).
|
|
2
|
+
|
|
3
|
+
Grounds answers in the user's stacks (drift, locks, run-all, auth) and proxies to
|
|
4
|
+
Claude when ANTHROPIC_API_KEY is set. The model key never touches the browser.
|
|
5
|
+
Falls back to canned, useful answers when no key / SDK is available so the chat
|
|
6
|
+
always works offline.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
|
|
13
|
+
MODEL = "claude-haiku-4-5" # mirrors the "Haiku" badge in the frontend
|
|
14
|
+
|
|
15
|
+
PERSONA = (
|
|
16
|
+
"You are TerraUi Assistant, an expert platform/SRE engineer specializing in "
|
|
17
|
+
"Terraform and Terragrunt across AWS, GCP and Azure. The user manages many IaC "
|
|
18
|
+
"stacks in one UI (drift, state locking, run-all orchestration, PR-triggered "
|
|
19
|
+
"plans). Answer concisely and practically, with short code/command snippets "
|
|
20
|
+
"where useful. Keep under 170 words. You may read state and run read-only "
|
|
21
|
+
"commands, but never apply changes without explicit human approval."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _fallback(q: str) -> str:
|
|
26
|
+
low = q.lower()
|
|
27
|
+
if "drift" in low:
|
|
28
|
+
return (
|
|
29
|
+
"Drift means the live resource no longer matches your recorded state — something "
|
|
30
|
+
"changed outside Terraform.\n\nInspect it: open the stack → Drift tab for attribute-level "
|
|
31
|
+
"state-vs-actual. To reconcile, run a plan — Terraform shows the changes needed to bring "
|
|
32
|
+
"reality back in line, then apply.\n\nPrevent it: restrict console/CLI write access in prod "
|
|
33
|
+
"and rely on PR-driven applies."
|
|
34
|
+
)
|
|
35
|
+
if "lock" in low:
|
|
36
|
+
return (
|
|
37
|
+
"State locking stops two applies from corrupting state.\n\n"
|
|
38
|
+
"• AWS (S3 backend): add a DynamoDB table → dynamodb_table = \"acme-tf-locks\".\n"
|
|
39
|
+
"• GCS / azurerm: locking is built in.\n\nIn TerraUi the Lock column shows who holds it. "
|
|
40
|
+
"If a run dies and leaves a stale lock, release it from the stack's Overview "
|
|
41
|
+
"(or terraform force-unlock LOCK_ID)."
|
|
42
|
+
)
|
|
43
|
+
if "run-all" in low or "order" in low or "depend" in low:
|
|
44
|
+
return (
|
|
45
|
+
"terragrunt run-all apply walks every unit and applies them in dependency order (from "
|
|
46
|
+
"dependency{} blocks) — e.g. networking before eks-cluster.\n\nPlain terraform apply only "
|
|
47
|
+
"touches the current module. Use the Dependencies view to preview the run-all order before "
|
|
48
|
+
"you trigger it."
|
|
49
|
+
)
|
|
50
|
+
if "adc" in low or "gcloud" in low or "gcp" in low:
|
|
51
|
+
return (
|
|
52
|
+
"GCP needs two logins:\n\n gcloud auth login # your user identity for gcloud\n"
|
|
53
|
+
" gcloud auth application-default login # ADC, what Terraform uses\n\nThe Terraform google "
|
|
54
|
+
"provider reads ADC, not your gcloud user session — that's why both are required. Set the "
|
|
55
|
+
"project with gcloud config set project."
|
|
56
|
+
)
|
|
57
|
+
if "import" in low:
|
|
58
|
+
return (
|
|
59
|
+
"Use terraform import to bring an existing resource under management:\n\n"
|
|
60
|
+
" terraform import aws_s3_bucket.logs acme-logs-bucket\n\nDefine the resource block first, "
|
|
61
|
+
"import, then run plan to confirm zero diff. For bulk imports, Terraform 1.5+ supports "
|
|
62
|
+
"import{} blocks in config."
|
|
63
|
+
)
|
|
64
|
+
return (
|
|
65
|
+
"I can help with Terraform/Terragrunt: drift, state & locking, run-all ordering, provider auth "
|
|
66
|
+
"(AWS/GCP/Azure), plan/apply flags, imports and module structure. Ask me something specific — "
|
|
67
|
+
'e.g. "why is eks-cluster drifting?" or "set up remote state locking".'
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def chat(question: str, context: str = "") -> dict:
|
|
72
|
+
"""Return {reply, model, grounded} for a single assistant turn."""
|
|
73
|
+
question = (question or "").strip()
|
|
74
|
+
if not question:
|
|
75
|
+
return {"reply": "", "model": "none", "grounded": False}
|
|
76
|
+
|
|
77
|
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
|
78
|
+
if not api_key:
|
|
79
|
+
return {"reply": _fallback(question), "model": "fallback", "grounded": False}
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
import anthropic # optional dependency
|
|
83
|
+
except ImportError:
|
|
84
|
+
return {"reply": _fallback(question), "model": "fallback", "grounded": False}
|
|
85
|
+
|
|
86
|
+
system = PERSONA + (f"\n\nCurrent stack context:\n{context}" if context else "")
|
|
87
|
+
try:
|
|
88
|
+
client = anthropic.AsyncAnthropic(api_key=api_key)
|
|
89
|
+
resp = await client.messages.create(
|
|
90
|
+
model=MODEL,
|
|
91
|
+
max_tokens=1024,
|
|
92
|
+
system=system,
|
|
93
|
+
messages=[{"role": "user", "content": question}],
|
|
94
|
+
)
|
|
95
|
+
text = "".join(b.text for b in resp.content if getattr(b, "type", "") == "text")
|
|
96
|
+
return {"reply": text.strip() or _fallback(question), "model": MODEL, "grounded": bool(context)}
|
|
97
|
+
except Exception:
|
|
98
|
+
return {"reply": _fallback(question), "model": "fallback", "grounded": False}
|
terraui/cli.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""TerraUi CLI (BACKEND_SPEC §2): start | scan | setup | server | agent."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
from . import __version__
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(add_completion=False, help="Local-first control plane for Terraform & Terragrunt.")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _version_cb(value: bool):
|
|
21
|
+
if value:
|
|
22
|
+
typer.echo(f"terraui {__version__}")
|
|
23
|
+
raise typer.Exit()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback()
|
|
27
|
+
def main(
|
|
28
|
+
version: bool = typer.Option(False, "--version", callback=_version_cb, is_eager=True, help="Show version and exit."),
|
|
29
|
+
):
|
|
30
|
+
"""TerraUi — inventory, plan/apply, drift, locks and a cloud shell, in one UI."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---- preflight helpers (cloud SDK detect / install / auth) ----------------
|
|
34
|
+
def _clouds_from_stacks(stacks: list[dict]) -> list[str]:
|
|
35
|
+
seen: list[str] = []
|
|
36
|
+
for s in stacks:
|
|
37
|
+
for c in (s.get("clouds") or [s.get("prov")]):
|
|
38
|
+
if c and c not in seen:
|
|
39
|
+
seen.append(c)
|
|
40
|
+
return seen
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _status_line(st) -> str:
|
|
44
|
+
if not st.installed:
|
|
45
|
+
return typer.style(f" ✗ {st.name:6} CLI not installed", fg="yellow")
|
|
46
|
+
if not st.authed:
|
|
47
|
+
return typer.style(f" ⚠ {st.name:6} {st.version} · not authenticated ({st.detail})", fg="yellow")
|
|
48
|
+
return typer.style(f" ✓ {st.name:6} {st.version} · {st.detail}", fg="green")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _quick_check(provider_ids: list[str]) -> None:
|
|
52
|
+
"""Read-only summary printed before serving. Never blocks."""
|
|
53
|
+
from .clouds import preflight
|
|
54
|
+
|
|
55
|
+
if not provider_ids:
|
|
56
|
+
return
|
|
57
|
+
typer.echo("Cloud SDK status (providers used by your stacks):")
|
|
58
|
+
needs_setup = False
|
|
59
|
+
for st in preflight.check_all(provider_ids):
|
|
60
|
+
typer.echo(_status_line(st))
|
|
61
|
+
if not st.installed or not st.authed:
|
|
62
|
+
needs_setup = True
|
|
63
|
+
if needs_setup:
|
|
64
|
+
typer.echo(typer.style(" → run `terraui setup` to install / authenticate (you can skip providers).", fg="cyan"))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _interactive_setup(provider_ids: list[str], assume_yes: bool = False) -> None:
|
|
68
|
+
"""Per-provider install + auth, fully skippable. Shows the exact command first."""
|
|
69
|
+
from .clouds import preflight
|
|
70
|
+
|
|
71
|
+
interactive = sys.stdin.isatty() and not assume_yes
|
|
72
|
+
typer.echo(typer.style(f"\nTerraUi setup — configuring: {', '.join(provider_ids)}\n", bold=True))
|
|
73
|
+
|
|
74
|
+
for pid in provider_ids:
|
|
75
|
+
st = preflight.check(pid)
|
|
76
|
+
typer.echo(typer.style(f"{st.name}", bold=True))
|
|
77
|
+
|
|
78
|
+
# 1) install if missing
|
|
79
|
+
if not st.installed:
|
|
80
|
+
argv, url = preflight.install_command(pid)
|
|
81
|
+
if argv:
|
|
82
|
+
cmd = " ".join(argv)
|
|
83
|
+
typer.echo(f" CLI not found. Install command: {typer.style(cmd, fg='cyan')}")
|
|
84
|
+
do = assume_yes or (interactive and typer.confirm(" Run it now?", default=True))
|
|
85
|
+
if do:
|
|
86
|
+
ok = preflight.run_install(pid)
|
|
87
|
+
typer.echo(" installed." if ok else typer.style(" install failed — see output above.", fg="red"))
|
|
88
|
+
st = preflight.check(pid)
|
|
89
|
+
else:
|
|
90
|
+
typer.echo(f" skipped. Install later: {cmd} (docs: {url})")
|
|
91
|
+
else:
|
|
92
|
+
typer.echo(f" No package manager detected. Install manually: {typer.style(url, fg='cyan')}")
|
|
93
|
+
if not st.installed:
|
|
94
|
+
typer.echo(" → skipping auth (CLI not present). Configure later with `terraui setup`.\n")
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
# 2) authenticate if needed
|
|
98
|
+
if st.authed:
|
|
99
|
+
typer.echo(typer.style(f" ✓ already authenticated · {st.detail}\n", fg="green"))
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
typer.echo(f" Not authenticated ({st.detail}).")
|
|
103
|
+
steps = preflight.PROVIDERS[pid].login
|
|
104
|
+
if not interactive:
|
|
105
|
+
cmds = "; ".join(" ".join(a) for _, a in steps)
|
|
106
|
+
typer.echo(f" Authenticate later by running: {cmds}\n")
|
|
107
|
+
continue
|
|
108
|
+
if not typer.confirm(" Authenticate now?", default=True):
|
|
109
|
+
cmds = " | ".join(" ".join(a) for _, a in steps)
|
|
110
|
+
typer.echo(f" skipped. Authenticate later: {cmds}\n")
|
|
111
|
+
continue
|
|
112
|
+
for i, (desc, argv) in enumerate(steps):
|
|
113
|
+
typer.echo(f" → {desc}: {typer.style(' '.join(argv), fg='cyan')}")
|
|
114
|
+
if typer.confirm(" Run this step?", default=True):
|
|
115
|
+
preflight.run_login(pid, i)
|
|
116
|
+
final = preflight.check(pid)
|
|
117
|
+
typer.echo((typer.style(f" ✓ authenticated · {final.detail}\n", fg="green")
|
|
118
|
+
if final.authed else typer.style(" still not authenticated — re-run when ready.\n", fg="yellow")))
|
|
119
|
+
|
|
120
|
+
typer.echo("Setup complete. Skipped providers can be configured anytime with `terraui setup`.\n")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@app.command()
|
|
124
|
+
def setup(
|
|
125
|
+
path: Optional[Path] = typer.Argument(None, help="IaC root — limits setup to the clouds your stacks use."),
|
|
126
|
+
providers: Optional[str] = typer.Option(None, "--providers", help="Comma list to configure: aws,gcp,azure."),
|
|
127
|
+
yes: bool = typer.Option(False, "--yes", help="Non-interactive: install where possible, skip browser logins."),
|
|
128
|
+
):
|
|
129
|
+
"""Detect, install and authenticate the cloud SDKs (gcloud/aws/az). Per-provider, skippable."""
|
|
130
|
+
if providers:
|
|
131
|
+
ids = [p.strip() for p in providers.split(",") if p.strip() in ("aws", "gcp", "azure")]
|
|
132
|
+
else:
|
|
133
|
+
ids = []
|
|
134
|
+
try:
|
|
135
|
+
from .discovery import scan_roots
|
|
136
|
+
|
|
137
|
+
stacks = [s.model_dump() for s in scan_roots([path or Path.cwd()])]
|
|
138
|
+
ids = _clouds_from_stacks(stacks)
|
|
139
|
+
except Exception:
|
|
140
|
+
ids = []
|
|
141
|
+
if not ids:
|
|
142
|
+
ids = ["aws", "gcp", "azure"]
|
|
143
|
+
_interactive_setup(ids, assume_yes=yes)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@app.command()
|
|
147
|
+
def start(
|
|
148
|
+
path: Optional[Path] = typer.Argument(None, help="IaC root to scan (default: cwd)."),
|
|
149
|
+
port: int = typer.Option(8787, help="Port to serve on."),
|
|
150
|
+
host: str = typer.Option("127.0.0.1", help="Bind address (local mode: localhost)."),
|
|
151
|
+
scan: list[Path] = typer.Option(None, "--scan", help="Extra roots/globs to scan."),
|
|
152
|
+
no_open: bool = typer.Option(False, "--no-open", help="Do not auto-open the browser."),
|
|
153
|
+
shell: Optional[str] = typer.Option(None, "--shell", help="Executor shell: powershell|pwsh|zsh|bash."),
|
|
154
|
+
drift_interval: str = typer.Option("30m", "--drift-interval", help="Background drift cadence (0 = off)."),
|
|
155
|
+
demo: bool = typer.Option(False, "--demo", help="Force the bundled demo dataset."),
|
|
156
|
+
check: bool = typer.Option(False, "--check", help="Run interactive cloud-SDK setup before serving."),
|
|
157
|
+
skip_checks: bool = typer.Option(False, "--skip-checks", help="Skip the cloud-SDK status check."),
|
|
158
|
+
):
|
|
159
|
+
"""Local mode: scan PATH, serve the UI + executor on http://host:port."""
|
|
160
|
+
import uvicorn
|
|
161
|
+
|
|
162
|
+
from .server.app import Config, create_app
|
|
163
|
+
|
|
164
|
+
roots = [p for p in ([path] if path else []) + list(scan or []) if p]
|
|
165
|
+
if not roots:
|
|
166
|
+
roots = [Path.cwd()]
|
|
167
|
+
cfg = Config(scan_roots=[Path(r) for r in roots], shell=shell, demo=demo,
|
|
168
|
+
drift_interval_min=_parse_minutes(drift_interval))
|
|
169
|
+
|
|
170
|
+
app_obj = create_app(cfg)
|
|
171
|
+
state = app_obj.state.terraui
|
|
172
|
+
needed = [] if state.demo else _clouds_from_stacks(state.stacks)
|
|
173
|
+
|
|
174
|
+
if check and needed:
|
|
175
|
+
_interactive_setup(needed)
|
|
176
|
+
elif not skip_checks and needed:
|
|
177
|
+
_quick_check(needed)
|
|
178
|
+
|
|
179
|
+
url = f"http://{host}:{port}"
|
|
180
|
+
mode = "demo dataset" if state.demo else f"{len(state.stacks)} unit(s) discovered"
|
|
181
|
+
typer.echo(f"TerraUi {__version__} · {mode} · {url}")
|
|
182
|
+
if not no_open:
|
|
183
|
+
threading.Thread(target=lambda: (time.sleep(1.0), webbrowser.open(url)), daemon=True).start()
|
|
184
|
+
uvicorn.run(app_obj, host=host, port=port, log_level="warning")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def scan(
|
|
189
|
+
path: Optional[Path] = typer.Argument(None, help="IaC root to scan (default: cwd)."),
|
|
190
|
+
as_json: bool = typer.Option(False, "--json", help="Print discovered units as JSON."),
|
|
191
|
+
):
|
|
192
|
+
"""Discover units and print them (no server) — for CI / debugging."""
|
|
193
|
+
from .discovery import scan_roots
|
|
194
|
+
|
|
195
|
+
root = path or Path.cwd()
|
|
196
|
+
stacks = scan_roots([root])
|
|
197
|
+
if as_json:
|
|
198
|
+
typer.echo(json.dumps([s.model_dump() for s in stacks], indent=2))
|
|
199
|
+
return
|
|
200
|
+
if not stacks:
|
|
201
|
+
typer.echo(f"No Terraform/Terragrunt units found under {root}")
|
|
202
|
+
return
|
|
203
|
+
typer.echo(f"Discovered {len(stacks)} unit(s) under {root}:")
|
|
204
|
+
for s in stacks:
|
|
205
|
+
deps = f" deps: {', '.join(s.deps)}" if s.deps else ""
|
|
206
|
+
typer.echo(f" {s.tool:10} {s.env:8} {s.path} [{','.join(s.clouds) or '?'}]{deps}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.command()
|
|
210
|
+
def server(
|
|
211
|
+
config: Optional[Path] = typer.Option(None, "--config", help="terraui.yaml (team mode)."),
|
|
212
|
+
port: int = typer.Option(8787),
|
|
213
|
+
host: str = typer.Option("0.0.0.0"),
|
|
214
|
+
):
|
|
215
|
+
"""Team mode: shared control plane (SSO/RBAC/Postgres). [scaffold]"""
|
|
216
|
+
typer.echo("Server mode is scaffolded — see BACKEND_SPEC §11. Use `terraui start` for local mode.")
|
|
217
|
+
raise typer.Exit(code=1)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
@app.command()
|
|
221
|
+
def agent(
|
|
222
|
+
server_url: str = typer.Option(..., "--server", help="Control-plane URL to register with."),
|
|
223
|
+
label: str = typer.Option("runner", "--label", help="Agent label, e.g. prod-runner."),
|
|
224
|
+
):
|
|
225
|
+
"""Remote executor that registers with a server. [scaffold]"""
|
|
226
|
+
typer.echo(f"Agent mode is scaffolded — would register '{label}' with {server_url}. See BACKEND_SPEC §11.")
|
|
227
|
+
raise typer.Exit(code=1)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _parse_minutes(value: str) -> int:
|
|
231
|
+
value = (value or "0").strip().lower()
|
|
232
|
+
if value in ("0", "off", ""):
|
|
233
|
+
return 0
|
|
234
|
+
if value.endswith("m"):
|
|
235
|
+
return int(value[:-1] or 0)
|
|
236
|
+
if value.endswith("h"):
|
|
237
|
+
return int(value[:-1] or 0) * 60
|
|
238
|
+
return int(value)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
if __name__ == "__main__":
|
|
242
|
+
app()
|
|
File without changes
|
terraui/clouds/auth.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Cloud SDK auth detection (BACKEND_SPEC §7).
|
|
2
|
+
|
|
3
|
+
Read-only probes per cloud, cached ~60s. GCP requires BOTH a user login and ADC.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
|
|
12
|
+
from ..execution.executor import run_capture
|
|
13
|
+
from ..models import CloudAuth
|
|
14
|
+
|
|
15
|
+
_CACHE: dict[str, tuple[float, CloudAuth]] = {}
|
|
16
|
+
_TTL = 60.0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def _aws() -> CloudAuth:
|
|
20
|
+
code, out = await run_capture(["aws", "sts", "get-caller-identity", "--output", "json"])
|
|
21
|
+
name, cli = "AWS", await _version("aws", "--version")
|
|
22
|
+
if code == 0:
|
|
23
|
+
try:
|
|
24
|
+
data = json.loads(out)
|
|
25
|
+
acct, arn = data.get("Account", ""), data.get("Arn", "")
|
|
26
|
+
return CloudAuth(id="aws", name=name, cli=cli, ok=True, status="authenticated", detail=f"{acct} · {arn}")
|
|
27
|
+
except json.JSONDecodeError:
|
|
28
|
+
pass
|
|
29
|
+
return CloudAuth(id="aws", name=name, cli=cli, ok=False, status="re-auth", detail="not authenticated · aws sso login")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def _gcp() -> CloudAuth:
|
|
33
|
+
cli = await _version("gcloud", "--version")
|
|
34
|
+
adc_code, _ = await run_capture(["gcloud", "auth", "application-default", "print-access-token"])
|
|
35
|
+
list_code, list_out = await run_capture(["gcloud", "auth", "list", "--format=value(account)"])
|
|
36
|
+
active = [ln for ln in list_out.splitlines() if ln.strip()]
|
|
37
|
+
if adc_code == 0 and list_code == 0 and active:
|
|
38
|
+
return CloudAuth(id="gcp", name="GCP", cli=cli, ok=True, status="authenticated", detail=f"{active[0]} · ADC active")
|
|
39
|
+
if list_code == 0 and active and adc_code != 0:
|
|
40
|
+
return CloudAuth(id="gcp", name="GCP", cli=cli, ok=False, status="re-auth", detail="run gcloud auth application-default login")
|
|
41
|
+
return CloudAuth(id="gcp", name="GCP", cli=cli, ok=False, status="re-auth", detail="run gcloud auth login")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _azure() -> CloudAuth:
|
|
45
|
+
cli = await _version("az", "version")
|
|
46
|
+
code, out = await run_capture(["az", "account", "show", "--output", "json"])
|
|
47
|
+
if code == 0:
|
|
48
|
+
try:
|
|
49
|
+
data = json.loads(out)
|
|
50
|
+
return CloudAuth(id="azure", name="Azure", cli=cli, ok=True, status="authenticated", detail=f"{data.get('name','')} · {data.get('id','')}")
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
pass
|
|
53
|
+
return CloudAuth(id="azure", name="Azure", cli=cli, ok=False, status="re-auth", detail="token expired · run az login")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _version(exe: str, *args: str) -> str:
|
|
57
|
+
code, out = await run_capture([exe, *args], timeout=10)
|
|
58
|
+
if code != 0:
|
|
59
|
+
return f"{exe} (not found)"
|
|
60
|
+
m = re.search(r"(\d+\.\d+(?:\.\d+)?)", out)
|
|
61
|
+
return f"{exe} {m.group(1)}" if m else exe
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
async def probe_all(force: bool = False) -> list[CloudAuth]:
|
|
65
|
+
now = time.time()
|
|
66
|
+
results: list[CloudAuth] = []
|
|
67
|
+
probes = {"aws": _aws, "gcp": _gcp, "azure": _azure}
|
|
68
|
+
for cid, fn in probes.items():
|
|
69
|
+
cached = _CACHE.get(cid)
|
|
70
|
+
if not force and cached and now - cached[0] < _TTL:
|
|
71
|
+
results.append(cached[1])
|
|
72
|
+
continue
|
|
73
|
+
auth = await fn()
|
|
74
|
+
_CACHE[cid] = (now, auth)
|
|
75
|
+
results.append(auth)
|
|
76
|
+
return results
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Cloud SDK preflight — detect, (optionally) install, and authenticate the
|
|
2
|
+
provider CLIs TerraUi shells out to (BACKEND_SPEC §7).
|
|
3
|
+
|
|
4
|
+
Read-only by default: detection runs `--version` and the auth probes only. Any
|
|
5
|
+
state-changing step (installing a CLI, running a login) is shown as the exact
|
|
6
|
+
command and gated behind explicit per-provider confirmation. Nothing is stored;
|
|
7
|
+
auth uses each SDK's own credential flow. Providers can be skipped and configured
|
|
8
|
+
later — a GCP-only user need not touch AWS/Azure.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import platform
|
|
15
|
+
import re
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Callable, Optional
|
|
20
|
+
|
|
21
|
+
IS_WIN = platform.system() == "Windows"
|
|
22
|
+
IS_MAC = platform.system() == "Darwin"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# ---- injectable primitives (monkeypatched in tests; never run installs there) ----
|
|
26
|
+
def _which(exe: str) -> Optional[str]:
|
|
27
|
+
return shutil.which(exe)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run(argv: list[str], timeout: float = 15, interactive: bool = False) -> tuple[int, str]:
|
|
31
|
+
"""Run a command. interactive=True inherits the terminal (for logins/installs)."""
|
|
32
|
+
try:
|
|
33
|
+
if interactive:
|
|
34
|
+
return subprocess.run(argv).returncode or 0, ""
|
|
35
|
+
p = subprocess.run(argv, capture_output=True, text=True, timeout=timeout)
|
|
36
|
+
return p.returncode, (p.stdout or "") + (p.stderr or "")
|
|
37
|
+
except (FileNotFoundError, subprocess.TimeoutExpired) as exc:
|
|
38
|
+
return 127, str(exc)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def package_managers() -> list[str]:
|
|
42
|
+
"""Available package managers on this machine, in preference order."""
|
|
43
|
+
candidates = ["winget", "choco", "scoop"] if IS_WIN else (["brew"] if IS_MAC else ["brew", "apt-get", "dnf"])
|
|
44
|
+
return [pm for pm in candidates if _which(pm)]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Provider:
|
|
49
|
+
id: str # aws | gcp | azure
|
|
50
|
+
name: str # AWS | GCP | Azure
|
|
51
|
+
exe: str # aws | gcloud | az
|
|
52
|
+
docs_url: str
|
|
53
|
+
install: dict = field(default_factory=dict) # pkg-manager -> argv
|
|
54
|
+
install_url: str = "" # manual installer link
|
|
55
|
+
login: list = field(default_factory=list) # [(description, argv), ...]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
PROVIDERS: dict[str, Provider] = {
|
|
59
|
+
"aws": Provider(
|
|
60
|
+
id="aws", name="AWS", exe="aws",
|
|
61
|
+
docs_url="https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
|
|
62
|
+
install={
|
|
63
|
+
"winget": ["winget", "install", "-e", "--id", "Amazon.AWSCLI", "--accept-source-agreements", "--accept-package-agreements"],
|
|
64
|
+
"choco": ["choco", "install", "awscli", "-y"],
|
|
65
|
+
"brew": ["brew", "install", "awscli"],
|
|
66
|
+
},
|
|
67
|
+
install_url="https://awscli.amazonaws.com/AWSCLIV2.msi" if IS_WIN else "https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html",
|
|
68
|
+
login=[
|
|
69
|
+
("Sign in with AWS IAM Identity Center (SSO)", ["aws", "configure", "sso"]),
|
|
70
|
+
("Configure static access keys", ["aws", "configure"]),
|
|
71
|
+
],
|
|
72
|
+
),
|
|
73
|
+
"gcp": Provider(
|
|
74
|
+
id="gcp", name="GCP", exe="gcloud",
|
|
75
|
+
docs_url="https://cloud.google.com/sdk/docs/install",
|
|
76
|
+
install={
|
|
77
|
+
"winget": ["winget", "install", "-e", "--id", "Google.CloudSDK", "--accept-source-agreements", "--accept-package-agreements"],
|
|
78
|
+
"choco": ["choco", "install", "gcloudsdk", "-y"],
|
|
79
|
+
"brew": ["brew", "install", "--cask", "google-cloud-sdk"],
|
|
80
|
+
},
|
|
81
|
+
install_url="https://cloud.google.com/sdk/docs/install",
|
|
82
|
+
# both are required: user login for the gcloud CLI, ADC for the Terraform google provider
|
|
83
|
+
login=[
|
|
84
|
+
("gcloud user login (CLI identity)", ["gcloud", "auth", "login"]),
|
|
85
|
+
("Application Default Credentials (used by Terraform)", ["gcloud", "auth", "application-default", "login"]),
|
|
86
|
+
],
|
|
87
|
+
),
|
|
88
|
+
"azure": Provider(
|
|
89
|
+
id="azure", name="Azure", exe="az",
|
|
90
|
+
docs_url="https://learn.microsoft.com/cli/azure/install-azure-cli",
|
|
91
|
+
install={
|
|
92
|
+
"winget": ["winget", "install", "-e", "--id", "Microsoft.AzureCLI", "--accept-source-agreements", "--accept-package-agreements"],
|
|
93
|
+
"choco": ["choco", "install", "azure-cli", "-y"],
|
|
94
|
+
"brew": ["brew", "install", "azure-cli"],
|
|
95
|
+
},
|
|
96
|
+
install_url="https://aka.ms/installazurecliwindows" if IS_WIN else "https://learn.microsoft.com/cli/azure/install-azure-cli",
|
|
97
|
+
login=[("az login (opens browser)", ["az", "login"])],
|
|
98
|
+
),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class Status:
|
|
104
|
+
id: str
|
|
105
|
+
name: str
|
|
106
|
+
installed: bool = False
|
|
107
|
+
version: str = ""
|
|
108
|
+
authed: bool = False
|
|
109
|
+
detail: str = ""
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _version(p: Provider) -> str:
|
|
113
|
+
args = ["version"] if p.id == "azure" else ["--version"]
|
|
114
|
+
code, out = _run([p.exe, *args], timeout=10)
|
|
115
|
+
if code == 127:
|
|
116
|
+
return ""
|
|
117
|
+
m = re.search(r"(\d+\.\d+(?:\.\d+)?)", out)
|
|
118
|
+
return f"{p.exe} {m.group(1)}" if m else p.exe
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _auth(p: Provider) -> tuple[bool, str]:
|
|
122
|
+
if p.id == "aws":
|
|
123
|
+
code, out = _run([p.exe, "sts", "get-caller-identity", "--output", "json"])
|
|
124
|
+
if code == 0:
|
|
125
|
+
try:
|
|
126
|
+
d = json.loads(out)
|
|
127
|
+
return True, f"{d.get('Account','')} · {d.get('Arn','')}"
|
|
128
|
+
except json.JSONDecodeError:
|
|
129
|
+
pass
|
|
130
|
+
return False, "not authenticated · aws sso login"
|
|
131
|
+
if p.id == "gcp":
|
|
132
|
+
adc, _ = _run([p.exe, "auth", "application-default", "print-access-token"])
|
|
133
|
+
lst, out = _run([p.exe, "auth", "list", "--format=value(account)"])
|
|
134
|
+
accts = [x for x in out.splitlines() if x.strip()]
|
|
135
|
+
if adc == 0 and lst == 0 and accts:
|
|
136
|
+
return True, f"{accts[0]} · ADC active"
|
|
137
|
+
if accts:
|
|
138
|
+
return False, "ADC missing · gcloud auth application-default login"
|
|
139
|
+
return False, "not authenticated · gcloud auth login"
|
|
140
|
+
# azure
|
|
141
|
+
code, out = _run([p.exe, "account", "show", "--output", "json"])
|
|
142
|
+
if code == 0:
|
|
143
|
+
try:
|
|
144
|
+
d = json.loads(out)
|
|
145
|
+
return True, f"{d.get('name','')} · {d.get('id','')}"
|
|
146
|
+
except json.JSONDecodeError:
|
|
147
|
+
pass
|
|
148
|
+
return False, "token expired · az login"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def check(provider_id: str) -> Status:
|
|
152
|
+
"""Read-only status for one provider."""
|
|
153
|
+
p = PROVIDERS[provider_id]
|
|
154
|
+
st = Status(id=p.id, name=p.name)
|
|
155
|
+
if not _which(p.exe):
|
|
156
|
+
st.detail = "CLI not installed"
|
|
157
|
+
return st
|
|
158
|
+
st.installed = True
|
|
159
|
+
st.version = _version(p)
|
|
160
|
+
st.authed, st.detail = _auth(p)
|
|
161
|
+
return st
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def check_all(provider_ids: Optional[list[str]] = None) -> list[Status]:
|
|
165
|
+
return [check(pid) for pid in (provider_ids or list(PROVIDERS))]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def install_command(provider_id: str) -> tuple[Optional[list[str]], str]:
|
|
169
|
+
"""Best install argv for this machine + the manual-install URL."""
|
|
170
|
+
p = PROVIDERS[provider_id]
|
|
171
|
+
for pm in package_managers():
|
|
172
|
+
if pm in p.install:
|
|
173
|
+
return p.install[pm], p.install_url
|
|
174
|
+
return None, p.install_url
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---- interactive driver (used by `terraui setup`) ----
|
|
178
|
+
def run_install(provider_id: str) -> bool:
|
|
179
|
+
argv, url = install_command(provider_id)
|
|
180
|
+
if not argv:
|
|
181
|
+
return False
|
|
182
|
+
code, _ = _run(argv, interactive=True, timeout=900)
|
|
183
|
+
return code == 0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def run_login(provider_id: str, step_index: int) -> bool:
|
|
187
|
+
p = PROVIDERS[provider_id]
|
|
188
|
+
if step_index >= len(p.login):
|
|
189
|
+
return False
|
|
190
|
+
_, argv = p.login[step_index]
|
|
191
|
+
code, _ = _run(argv, interactive=True, timeout=900)
|
|
192
|
+
return code == 0
|