Add compliance scanning, run history, host details, config git integration, and test suite

Builds on initial v1 with: compliance job scheduling and result tracking,
structured run history with findings/log storage, host-level drift details
view, git-backed playbook/inventory support, and comprehensive test coverage
across all packages.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-24 20:41:38 -04:00
parent d0b7ecd0e8
commit a4c8fb91f6
39 changed files with 5319 additions and 370 deletions
+5
View File
@@ -3,6 +3,11 @@
*.exe~
ansibletui
ansibletui-linux
annsibletui-linux
# Local tooling
.claude/
tools.yaml
# Test binary, built with `go test -c`
*.test
+26
View File
@@ -0,0 +1,26 @@
.PHONY: build test test-unit test-integration test-all lint
build:
go build ./...
# Default: unit tests only (no external dependencies).
# Run after every code change.
test: test-unit
test-unit:
go test ./...
# Integration tests require:
# - ansible-playbook in PATH
# - ~/.ansibletui/inventory.yml
# - ~/.ansibletui/playbooks/
# Run after every playbook change.
test-integration:
go test -v -tags integration -timeout 120s ./test/integration/...
# Run everything.
test-all:
go test -tags integration -timeout 300s ./...
lint:
go vet ./...
+165
View File
@@ -0,0 +1,165 @@
# ansibleTUI
Terminal UI for running Ansible playbooks against a managed inventory. Browse hosts, check drift, apply playbooks, and review run history — without leaving the terminal.
Built with [Bubble Tea](https://github.com/charmbracelet/bubbletea) and [Lip Gloss](https://github.com/charmbracelet/lipgloss).
## Requirements
- **Go 1.21+** (to build from source)
- **`ansible`** and **`ansible-playbook`** on your `PATH` (e.g. macOS Homebrew, Linux packages, or WSL on Windows)
- **`git`** (only if you use git-backed playbooks or inventory)
## Install and run
```bash
git clone <your-repo-url> ansibleTUI
cd ansibleTUI
go build -o ansibletui .
./ansibletui
```
## Directory layout
| Path | Purpose |
|------|---------|
| `~/.config/ansibletui/ansibletui.yaml` | Application configuration |
| `~/.ansibletui/playbooks/` | Default playbooks directory / git clone |
| `~/.ansibletui/inventory/` | Default inventory git clone root |
| `~/.ansibletui/inventory/inventory.yml` | Default inventory file |
| `~/.ansibletui/runs/` | Saved playbook run logs and metadata |
Legacy installs may have had `~/.config/ansibletui/config.yaml` and `runs/` under config; the app migrates these on first launch.
## Configuration
See **[examples/ansibletui.example.yaml](examples/ansibletui.example.yaml)** for every supported field with comments.
```bash
mkdir -p ~/.config/ansibletui
cp examples/ansibletui.example.yaml ~/.config/ansibletui/ansibletui.yaml
# Edit remotes, paths, and enabled flags
```
If no config exists, the app creates `ansibletui.yaml` with sensible defaults under `~/.ansibletui/`.
### Git-backed playbooks and inventory
You can manage **two separate git repositories**:
- **Playbooks** — directory of Ansible playbook files
- **Inventory** — repo containing `inventory.yml` (or another name via `inventory_git.file`)
Per-repo settings:
| Field | Description |
|-------|-------------|
| `enabled` | Use git for this resource |
| `remote` | Git remote URL (required when enabled) |
| `branch` | Branch to clone / pull / push (default `main`) |
| `path` | Local directory |
| `on_missing` | `clone` — clone from remote; `init``git init`, initial commit, push |
| `sync_on_startup` | Pull (or bootstrap) when the app starts |
Without git, set `playbook_dir` and `inventory_path` only and leave `enabled: false`.
## Config tab (CFG)
From the home screen, select the **CFG** sidebar tab:
| Key | Action |
|-----|--------|
| `↑` / `↓` | Select playbooks or inventory repo |
| `←` / `→` | Select field (remote, branch, path, …) |
| `e` or `Enter` | Edit focused field (paste git URL into **remote**) |
| `Esc` | Cancel edit |
| `Enter` (while editing) | Save field to config |
| `s` | Sync (pull / clone) selected repo |
| `S` | Push selected repo |
| `t` | Toggle `enabled` for selected repo |
Edit `~/.config/ansibletui/ansibletui.yaml` directly for remotes, branches, and paths. Host changes write to the inventory file locally; commit and push via **S** or an external git client.
## Home screen shortcuts
| Key | Action |
|-----|--------|
| `↑` / `↓` | Move selection |
| `/` | Filter servers |
| `a` | Add server |
| `e` | Edit server |
| `d` | Delete server |
| `p` | Quick TCP reachability probe |
| `c` | Check (check + diff playbook flow) |
| `r` | Apply drifted |
| `Enter` | Open host drift details (servers panel) or run details (runs panel) |
| `Tab` | Cycle SERV / JOBS / CFG |
| `q` | Quit |
## Check / apply flow
1. Choose a playbook from the playbooks directory
2. Choose mode: check, check+diff, or apply
3. Preview the `ansible-playbook` command
4. Watch live output and recap
**Check mode is a dry run.** The app passes `--check` (and optionally `--diff`) to Ansible. Structured JSONL events are rendered into concise drift findings; `changed=1` still means one task **would** change the host, not that anything was applied. The server list shows **drift** and a per-host drift count when a check finds would-be changes. Use **Apply** only when you intend to make real changes.
Compliance mappings can use either simple playbook names or structured entries with tags:
```yaml
universal:
- site.yml
- playbook: site.yml
tags: [sudo, packages]
```
Some modules ignore check mode and can still modify the system; prefer idempotent modules or review the playbook.
## Inventory format
The TUI reads and writes a YAML inventory compatible with Ansible:
```yaml
all:
hosts:
media01:
ansible_host: 192.168.1.10
ansible_user: frank
ansible_port: 22
children:
media:
hosts:
media01: null
```
## Migration from older config
If `~/.config/ansibletui/config.yaml` exists and `ansibletui.yaml` does not:
1. Settings are copied to `ansibletui.yaml`
2. Legacy config is renamed to `config.yaml.migrated`
3. Run history under `~/.config/ansibletui/runs/` is moved to `~/.ansibletui/runs/` when the new runs dir is empty
## Troubleshooting
| Issue | What to do |
|-------|------------|
| `ansible not found in PATH` | Install Ansible or run from WSL; ensure `ansible-playbook` is available |
| `git` errors on sync | Check SSH keys / credentials; verify `remote` URL |
| Pull fails with diverged history | Resolve conflicts externally; sync uses `git pull --ff-only` |
| No playbooks listed | Confirm `playbook_dir` or `playbooks_git.path` contains `*.yml` playbooks at the repo root or one level down (e.g. `services/pi-hole.yml`); `roles/` and `group_vars/` are not listed |
| Path exists but is not a git repository | Remove or rename the directory, or point `path` elsewhere |
## Development
```bash
go build .
go run .
```
Design references live under [ux/](ux/).
## License
See repository license (if applicable).
+84
View File
@@ -0,0 +1,84 @@
# Example configuration for ansibleTUI
# Copy to: ~/.config/ansibletui/ansibletui.yaml
#
# mkdir -p ~/.config/ansibletui
# cp examples/ansibletui.example.yaml ~/.config/ansibletui/ansibletui.yaml
#
# On first run the app can also create ansibletui.yaml automatically and
# migrate a legacy ~/.config/ansibletui/config.yaml if present.
# --- Paths (used when git is disabled, or as fallbacks) ---
# Directory scanned for *.yml / *.yaml playbooks (excluding requirements, group_vars, etc.)
playbook_dir: ~/.ansibletui/playbooks
# Ansible inventory file (YAML format managed by the TUI)
inventory_path: ~/.ansibletui/inventory/inventory.yml
# Runtime hint: auto | wsl | native (reserved for future use)
runtime: auto
# Last selected playbook filename (updated by the TUI)
recent_playbook: ""
# --- Compliance mapping ---
#
# The app first looks for compliance.yaml in the playbook directory.
# If that file is absent, this inline mapping is used as a fallback.
#
# compliance:
# universal:
# - site.yml
# - playbook: site.yml
# tags: [sudo]
# groups:
# dns:
# - services/pi-hole.yml
# --- Playbooks git repository (optional) ---
playbooks_git:
# When true, EffectivePlaybookDir() uses path below; sync_on_startup runs git sync
enabled: false
# Git remote URL (required when enabled)
remote: git@github.com:you/ansible-playbooks.git
# Branch to clone / pull / push
branch: main
# Local clone directory
path: ~/.ansibletui/playbooks
# When path does not exist: clone (from remote) | init (empty repo, commit, push)
on_missing: clone
# Run git sync when the application starts
sync_on_startup: true
# --- Inventory git repository (optional) ---
inventory_git:
enabled: false
remote: git@github.com:you/ansible-inventory.git
branch: main
# Local clone root (inventory file lives inside this directory)
path: ~/.ansibletui/inventory
# Inventory filename relative to path
file: inventory.yml
# clone: pull existing repo | init: create repo and push (seeds empty inventory if needed)
on_missing: init
sync_on_startup: true
# --- Minimal setup (no git) ---
#
# playbook_dir: ~/homelab/ansible
# inventory_path: ~/.ansibletui/inventory.yml
# runtime: auto
# (omit playbooks_git / inventory_git or set enabled: false)
+18
View File
@@ -0,0 +1,18 @@
# Place this file at the root of your playbooks directory as compliance.yaml.
#
# Universal playbooks run against all inventory hosts.
universal:
- site.yml
# Optional structured entries run the same playbook for a focused tag set.
# This gives faster per-area drift feedback without splitting playbooks.
- playbook: site.yml
tags: [sudo]
- playbook: site.yml
tags: [packages]
# Group playbooks run with --limit <group>.
groups:
appliance:
- playbooks/xcp-guest-tools.yml
dns:
- services/pi-hole.yml
+46 -6
View File
@@ -40,11 +40,22 @@ func (m Mode) ModeLabel() string {
}
// BuildPlaybookArgs constructs the argv for ansible-playbook.
func BuildPlaybookArgs(inventoryPath, playbookPath, limit string, mode Mode) []string {
args := []string{"ansible-playbook", "-i", inventoryPath, playbookPath}
// playbookRel is relative to the playbook repo root (e.g. "site.yml", "security/ipv6-disable.yml").
// Run ansible-playbook with cwd set to that repo root so roles/ and ansible.cfg resolve.
func BuildPlaybookArgs(inventoryPath, playbookRel, limit string, mode Mode) []string {
return BuildPlaybookArgsWithTags(inventoryPath, playbookRel, limit, nil, mode)
}
// BuildPlaybookArgsWithTags constructs argv for a playbook optionally limited
// to a comma-separated tag set.
func BuildPlaybookArgsWithTags(inventoryPath, playbookRel, limit string, tags []string, mode Mode) []string {
args := []string{"ansible-playbook", "-i", inventoryPath, playbookRel}
if limit != "" {
args = append(args, "--limit", limit)
}
if len(tags) > 0 {
args = append(args, "--tags", strings.Join(tags, ","))
}
switch mode {
case ModeCheck:
args = append(args, "--check")
@@ -69,18 +80,47 @@ type Recap struct {
Skipped int
}
// DriftState returns "clean", "drift", "failed", or "unreachable".
// DriftState returns "clean", "drift", "failed", or "unreachable" (check-mode semantics).
func (r *Recap) DriftState() string {
return r.InterpretStatus(ModeCheckDiff)
}
// InterpretStatus maps PLAY RECAP counters to a run status for the given execution mode.
// In check modes, changed=N means N tasks would change the host (dry run — nothing applied).
// In apply mode, a successful run is "ok" even when tasks reported changed.
func (r *Recap) InterpretStatus(mode Mode) string {
if r.Unreachable > 0 {
return "unreachable"
}
if r.Failed > 0 {
return "failed"
}
if r.Changed > 0 {
return "drift"
switch mode {
case ModeApply:
return "ok"
default:
if r.Changed > 0 {
return "drift"
}
return "clean"
}
return "clean"
}
// ModeFromString parses a stored mode label from run history.
func ModeFromString(s string) Mode {
switch s {
case "check":
return ModeCheck
case "check+diff":
return ModeCheckDiff
default:
return ModeApply
}
}
// IsCheckMode reports whether the mode runs ansible-playbook with --check.
func (m Mode) IsCheckMode() bool {
return m == ModeCheck || m == ModeCheckDiff
}
// recapRe matches a single PLAY RECAP host line.
+273
View File
@@ -0,0 +1,273 @@
package ansible
import (
"strings"
"testing"
)
// ---- ParseRecap ----
func TestParseRecapClean(t *testing.T) {
out := recap("web01", 5, 0, 0, 0, 3)
got := ParseRecap(out)
if len(got) != 1 {
t.Fatalf("want 1 recap, got %d", len(got))
}
r := got[0]
if r.Host != "web01" || r.OK != 5 || r.Changed != 0 || r.Failed != 0 || r.Unreachable != 0 || r.Skipped != 3 {
t.Fatalf("unexpected recap: %+v", r)
}
}
func TestParseRecapDrift(t *testing.T) {
out := recap("web01", 4, 2, 0, 0, 0)
got := ParseRecap(out)
if len(got) != 1 || got[0].Changed != 2 {
t.Fatalf("unexpected: %+v", got)
}
}
func TestParseRecapFailed(t *testing.T) {
out := recap("web01", 3, 0, 0, 1, 0)
got := ParseRecap(out)
if len(got) != 1 || got[0].Failed != 1 {
t.Fatalf("unexpected: %+v", got)
}
}
func TestParseRecapUnreachable(t *testing.T) {
out := recap("web01", 0, 0, 1, 0, 0)
got := ParseRecap(out)
if len(got) != 1 || got[0].Unreachable != 1 || got[0].Failed != 0 {
t.Fatalf("unexpected: %+v", got)
}
}
func TestParseRecapMultipleHosts(t *testing.T) {
out := "PLAY RECAP *****\n" +
"adguard-1 : ok=6 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \n" +
"adguard-2 : ok=6 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 \n" +
"xen-orchestra : ok=2 changed=0 unreachable=0 failed=0 skipped=6 rescued=0 ignored=0 \n"
got := ParseRecap(out)
if len(got) != 3 {
t.Fatalf("want 3 recaps, got %d", len(got))
}
byHost := map[string]Recap{}
for _, r := range got {
byHost[r.Host] = r
}
if byHost["adguard-1"].Changed != 1 || byHost["xen-orchestra"].Skipped != 6 {
t.Fatalf("recaps: %+v", byHost)
}
}
func TestParseRecapNoRecapBlock(t *testing.T) {
out := "TASK [some task]\nok: [web01]\n\nPLAY [something] *****\n"
got := ParseRecap(out)
if len(got) != 0 {
t.Fatalf("want 0 recaps for output without PLAY RECAP, got %d", len(got))
}
}
func TestParseRecapEmpty(t *testing.T) {
got := ParseRecap("")
if len(got) != 0 {
t.Fatalf("want 0 for empty input, got %d", len(got))
}
}
// Regression: xcp-guest-tools check mode before the fix.
// The install task crashed, yielding changed=1 failed=1.
// InterpretStatus must return "failed" (not "drift") so the host is marked failed.
func TestParseRecapXcpGuestToolsPreFix(t *testing.T) {
out := recap("adguard-1", 6, 1, 0, 1, 0)
got := ParseRecap(out)
if len(got) != 1 {
t.Fatalf("want 1 recap, got %d", len(got))
}
r := got[0]
if r.Changed != 1 || r.Failed != 1 {
t.Fatalf("unexpected recap: %+v", r)
}
if status := r.InterpretStatus(ModeCheckDiff); status != "failed" {
t.Fatalf("pre-fix: want 'failed', got %q", status)
}
}
// Regression: xcp-guest-tools check mode after the fix.
// Install/Remove are skipped in check mode; only Download simulates (changed=1).
// InterpretStatus must return "drift" — host needs guest tools, no crash.
func TestParseRecapXcpGuestToolsPostFix(t *testing.T) {
out := recap("adguard-1", 6, 1, 0, 0, 0)
got := ParseRecap(out)
if len(got) != 1 {
t.Fatalf("want 1 recap, got %d", len(got))
}
r := got[0]
if r.Changed != 1 || r.Failed != 0 {
t.Fatalf("unexpected recap: %+v", r)
}
if status := r.InterpretStatus(ModeCheckDiff); status != "drift" {
t.Fatalf("post-fix: want 'drift', got %q", status)
}
}
func TestParseRecapSkippedFieldPresent(t *testing.T) {
out := "PLAY RECAP *****\n" +
"web01 : ok=2 changed=0 unreachable=0 failed=0 skipped=6 rescued=0 ignored=0 \n"
got := ParseRecap(out)
if len(got) != 1 || got[0].Skipped != 6 {
t.Fatalf("unexpected: %+v", got)
}
}
func TestParseRecapSkippedFieldAbsent(t *testing.T) {
// Older ansible output omits the skipped= field entirely.
out := "PLAY RECAP *****\n" +
"web01 : ok=2 changed=0 unreachable=0 failed=0\n"
got := ParseRecap(out)
if len(got) != 1 || got[0].Skipped != 0 {
t.Fatalf("unexpected: %+v", got)
}
}
// ---- InterpretStatus ----
func TestInterpretStatusTable(t *testing.T) {
cases := []struct {
name string
r Recap
mode Mode
wantStatus string
}{
// Unreachable always wins, regardless of mode.
{"unreachable-check", Recap{Unreachable: 1, Failed: 1, Changed: 1}, ModeCheckDiff, "unreachable"},
{"unreachable-apply", Recap{Unreachable: 1, Changed: 1}, ModeApply, "unreachable"},
// Failed wins over drift/clean.
{"failed-check", Recap{Failed: 1, Changed: 1}, ModeCheckDiff, "failed"},
{"failed-apply", Recap{Failed: 1}, ModeApply, "failed"},
// Changed in check mode = drift.
{"changed-check", Recap{OK: 3, Changed: 2}, ModeCheckDiff, "drift"},
{"changed-check-only", Recap{OK: 3, Changed: 1}, ModeCheck, "drift"},
// Changed in apply mode = successful apply (ok), NOT drift.
{"changed-apply", Recap{OK: 3, Changed: 2}, ModeApply, "ok"},
// All-zero clean.
{"clean-check", Recap{OK: 5}, ModeCheckDiff, "clean"},
{"clean-apply", Recap{OK: 5}, ModeApply, "ok"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := tc.r.InterpretStatus(tc.mode)
if got != tc.wantStatus {
t.Errorf("InterpretStatus(%+v, %v) = %q, want %q", tc.r, tc.mode, got, tc.wantStatus)
}
})
}
}
// ---- BuildPlaybookArgs ----
func TestBuildPlaybookArgsCheckDiff(t *testing.T) {
args := BuildPlaybookArgs("/inv.yml", "site.yml", "", ModeCheckDiff)
if !contains(args, "--check") || !contains(args, "--diff") {
t.Fatalf("check+diff args missing: %v", args)
}
if contains(args, "--limit") {
t.Fatalf("unexpected --limit in args: %v", args)
}
}
func TestBuildPlaybookArgsCheckOnly(t *testing.T) {
args := BuildPlaybookArgs("/inv.yml", "site.yml", "", ModeCheck)
if !contains(args, "--check") {
t.Fatalf("--check missing: %v", args)
}
if contains(args, "--diff") {
t.Fatalf("unexpected --diff: %v", args)
}
}
func TestBuildPlaybookArgsApply(t *testing.T) {
args := BuildPlaybookArgs("/inv.yml", "site.yml", "", ModeApply)
if contains(args, "--check") || contains(args, "--diff") {
t.Fatalf("unexpected check flags in apply mode: %v", args)
}
}
func TestBuildPlaybookArgsWithLimit(t *testing.T) {
args := BuildPlaybookArgs("/inv.yml", "site.yml", "dns", ModeCheckDiff)
idx := index(args, "--limit")
if idx < 0 || idx+1 >= len(args) || args[idx+1] != "dns" {
t.Fatalf("--limit dns not found in: %v", args)
}
}
func TestBuildPlaybookArgsWithTags(t *testing.T) {
args := BuildPlaybookArgsWithTags("/inv.yml", "site.yml", "all", []string{"sudo", "packages"}, ModeCheckDiff)
idx := index(args, "--tags")
if idx < 0 || idx+1 >= len(args) || args[idx+1] != "sudo,packages" {
t.Fatalf("--tags not found in: %v", args)
}
}
func TestBuildPlaybookArgsNoLimit(t *testing.T) {
args := BuildPlaybookArgs("/inv.yml", "site.yml", "", ModeApply)
if contains(args, "--limit") {
t.Fatalf("unexpected --limit for empty limit: %v", args)
}
}
// ---- helpers ----
// recap builds a minimal ansible-playbook output with a single PLAY RECAP line.
func recap(host string, ok, changed, unreachable, failed, skipped int) string {
return strings.Join([]string{
"PLAY RECAP *****",
strings.TrimRight(
host+strings.Repeat(" ", max(1, 27-len(host)))+
": ok="+itoa(ok)+
" changed="+itoa(changed)+
" unreachable="+itoa(unreachable)+
" failed="+itoa(failed)+
" skipped="+itoa(skipped)+
" rescued=0 ignored=0 ", " "),
"",
}, "\n")
}
func itoa(n int) string {
if n == 0 {
return "0"
}
s := ""
for n > 0 {
s = string(rune('0'+n%10)) + s
n /= 10
}
return s
}
func contains(slice []string, s string) bool {
for _, v := range slice {
if v == s {
return true
}
}
return false
}
func index(slice []string, s string) int {
for i, v := range slice {
if v == s {
return i
}
}
return -1
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
+317
View File
@@ -0,0 +1,317 @@
package ansible
import (
"bufio"
"encoding/json"
"fmt"
"sort"
"strings"
)
// Finding is one actionable event from an Ansible JSONL stream.
type Finding struct {
Host string `json:"host"`
Playbook string `json:"playbook,omitempty"`
Tags []string `json:"tags,omitempty"`
Play string `json:"play,omitempty"`
Task string `json:"task,omitempty"`
TaskPath string `json:"task_path,omitempty"`
Status string `json:"status"` // changed, failed, unreachable, skipped
Item string `json:"item,omitempty"`
Path string `json:"path,omitempty"`
Summary string `json:"summary,omitempty"`
Diagnostic string `json:"diagnostic,omitempty"`
Raw json.RawMessage `json:"raw,omitempty"`
}
// JSONLResult is the structured view derived from Ansible JSONL output.
type JSONLResult struct {
Recaps []Recap
Findings []Finding
Lines []string
}
type jsonlEvent struct {
Event string `json:"_event"`
Play jsonlPlay `json:"play"`
Task jsonlTask `json:"task"`
Hosts map[string]json.RawMessage `json:"hosts"`
Stats map[string]jsonlStats `json:"stats"`
}
type jsonlPlay struct {
Name string `json:"name"`
Path string `json:"path"`
}
type jsonlTask struct {
Name string `json:"name"`
Path string `json:"path"`
Tags []string `json:"tags"`
}
type jsonlStats struct {
OK int `json:"ok"`
Changed int `json:"changed"`
Failures int `json:"failures"`
Unreachable int `json:"unreachable"`
Skipped int `json:"skipped"`
}
type hostResult struct {
Changed bool `json:"changed"`
Msg string `json:"msg"`
Stdout string `json:"stdout"`
Stderr string `json:"stderr"`
Item any `json:"item"`
Action string `json:"action"`
Diff []diffEntry `json:"diff"`
Inv invocation `json:"invocation"`
Raw json.RawMessage `json:"-"`
}
type invocation struct {
ModuleArgs map[string]any `json:"module_args"`
}
type diffEntry struct {
BeforeHeader string `json:"before_header"`
AfterHeader string `json:"after_header"`
Before string `json:"before"`
After string `json:"after"`
}
// ParseJSONL parses ansible.posix.jsonl output. Non-JSON lines are ignored so
// callers can safely pass mixed logs or fall back to ParseRecap.
func ParseJSONL(output []byte, playbook string, tags []string) JSONLResult {
var result JSONLResult
scanner := bufio.NewScanner(strings.NewReader(string(output)))
scanner.Buffer(make([]byte, 1024*1024), 8*1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || !strings.HasPrefix(line, "{") {
continue
}
eventResult := ParseJSONLLine(line, playbook, tags)
if len(eventResult.Recaps) > 0 {
result.Recaps = append(result.Recaps, eventResult.Recaps...)
}
if len(eventResult.Findings) > 0 {
result.Findings = append(result.Findings, eventResult.Findings...)
}
if len(eventResult.Lines) > 0 {
result.Lines = append(result.Lines, eventResult.Lines...)
}
}
return result
}
// ParseJSONLLine parses one ansible.posix.jsonl line.
func ParseJSONLLine(line, playbook string, tags []string) JSONLResult {
var event jsonlEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
return JSONLResult{}
}
switch event.Event {
case "v2_playbook_on_stats":
recaps := make([]Recap, 0, len(event.Stats))
for host, stat := range event.Stats {
recaps = append(recaps, Recap{
Host: host,
OK: stat.OK,
Changed: stat.Changed,
Failed: stat.Failures,
Unreachable: stat.Unreachable,
Skipped: stat.Skipped,
})
}
sort.Slice(recaps, func(i, j int) bool { return recaps[i].Host < recaps[j].Host })
return JSONLResult{Recaps: recaps}
case "v2_runner_on_ok", "v2_runner_on_failed", "v2_runner_on_unreachable", "v2_runner_on_skipped":
return parseRunnerEvent(event, line, playbook, tags)
default:
return JSONLResult{}
}
}
func parseRunnerEvent(event jsonlEvent, line, playbook string, tags []string) JSONLResult {
status := eventStatus(event.Event)
var out JSONLResult
hosts := make([]string, 0, len(event.Hosts))
for host := range event.Hosts {
hosts = append(hosts, host)
}
sort.Strings(hosts)
for _, host := range hosts {
raw := event.Hosts[host]
var hr hostResult
hr.Raw = raw
_ = json.Unmarshal(raw, &hr)
if status == "ok" && !hr.Changed {
continue
}
findingStatus := status
if findingStatus == "ok" {
findingStatus = "changed"
}
f := Finding{
Host: host,
Playbook: playbook,
Tags: copyStrings(tags),
Play: event.Play.Name,
Task: event.Task.Name,
TaskPath: event.Task.Path,
Status: findingStatus,
Item: stringifyItem(hr.Item),
Path: resultPath(hr),
Summary: resultSummary(findingStatus, hr),
Diagnostic: DiagnoseUnreachable(hr.Msg),
Raw: json.RawMessage(line),
}
if len(f.Tags) == 0 {
f.Tags = copyStrings(event.Task.Tags)
}
out.Findings = append(out.Findings, f)
out.Lines = append(out.Lines, CompactFindingLine(f))
}
return out
}
func eventStatus(event string) string {
switch event {
case "v2_runner_on_failed":
return "failed"
case "v2_runner_on_unreachable":
return "unreachable"
case "v2_runner_on_skipped":
return "skipped"
default:
return "ok"
}
}
// CompactJSONLLine returns a concise display line for one JSONL event.
func CompactJSONLLine(line, playbook string, tags []string) (string, bool) {
result := ParseJSONLLine(line, playbook, tags)
if len(result.Lines) == 0 {
return "", false
}
return strings.Join(result.Lines, "\n"), true
}
// CompactFindingLine renders one finding for live output or run details.
func CompactFindingLine(f Finding) string {
task := f.Task
if task == "" {
task = f.Play
}
if task == "" {
task = f.Playbook
}
tag := ""
if len(f.Tags) > 0 {
tag = strings.Join(f.Tags, ",") + " "
}
target := f.Path
if target == "" {
target = f.Item
}
if target != "" {
target = " " + target
}
msg := f.Summary
if f.Diagnostic != "" {
msg = f.Diagnostic
}
if msg != "" {
msg = " - " + msg
}
return fmt.Sprintf("%-11s %-18s %s%s%s%s", f.Status, f.Host, tag, task, target, msg)
}
// DiagnoseUnreachable reduces common Ansible connection/bootstrap errors to a
// short human diagnostic. The original msg remains available in Summary.
func DiagnoseUnreachable(msg string) string {
lower := strings.ToLower(msg)
switch {
case msg == "":
return ""
case strings.Contains(lower, "failed to create temporary directory"):
return "Ansible bootstrap failed: cannot create remote tmp directory"
case strings.Contains(lower, "operation timed out") || strings.Contains(lower, "connection timed out"):
return "SSH timed out"
case strings.Contains(lower, "permission denied") || strings.Contains(lower, "authentication"):
return "SSH authentication or permission failed"
case strings.Contains(lower, "connection refused"):
return "SSH connection refused"
default:
return concise(msg, 140)
}
}
func resultSummary(status string, hr hostResult) string {
if status == "unreachable" {
return concise(hr.Msg, 240)
}
for _, s := range []string{hr.Msg, hr.Stdout, hr.Stderr} {
if strings.TrimSpace(s) != "" {
return concise(s, 160)
}
}
if status == "changed" {
return "would change"
}
return status
}
func resultPath(hr hostResult) string {
for _, d := range hr.Diff {
if d.AfterHeader != "" {
return d.AfterHeader
}
if d.BeforeHeader != "" {
return d.BeforeHeader
}
}
for _, key := range []string{"dest", "path", "name"} {
if v, ok := hr.Inv.ModuleArgs[key]; ok {
if s := fmt.Sprint(v); s != "" && s != "<nil>" {
return s
}
}
}
return ""
}
func stringifyItem(v any) string {
if v == nil {
return ""
}
switch t := v.(type) {
case string:
return t
default:
data, err := json.Marshal(t)
if err != nil {
return fmt.Sprint(t)
}
return string(data)
}
}
func concise(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if max > 0 && len(s) > max {
return s[:max-1] + "…"
}
return s
}
func copyStrings(in []string) []string {
if len(in) == 0 {
return nil
}
out := append([]string(nil), in...)
sort.Strings(out)
return out
}
+81
View File
@@ -0,0 +1,81 @@
package ansible
import (
"strings"
"testing"
)
func TestParseJSONLChangedWithDiffAndTags(t *testing.T) {
line := `{"_event":"v2_runner_on_ok","hosts":{"web01":{"changed":true,"diff":[{"after_header":"/etc/example.conf","before":""}],"invocation":{"module_args":{"dest":"/ignored"}}}},"task":{"name":"Deploy config","path":"roles/base/tasks/main.yml:4"}}`
got := ParseJSONLLine(line, "site.yml", []string{"base"})
if len(got.Findings) != 1 {
t.Fatalf("findings = %d", len(got.Findings))
}
f := got.Findings[0]
if f.Host != "web01" || f.Status != "changed" || f.Path != "/etc/example.conf" || f.Task != "Deploy config" {
t.Fatalf("unexpected finding: %+v", f)
}
if len(f.Tags) != 1 || f.Tags[0] != "base" {
t.Fatalf("tags = %#v", f.Tags)
}
if !strings.Contains(got.Lines[0], "Deploy config") {
t.Fatalf("compact line missing task: %q", got.Lines[0])
}
}
func TestParseJSONLUnreachableDiagnosticRemoteTmp(t *testing.T) {
line := `{"_event":"v2_runner_on_unreachable","hosts":{"kube-1":{"changed":false,"msg":"Task failed: Failed to create temporary directory. Consider changing the remote tmp path in ansible.cfg"}},"task":{"name":"Gathering Facts"}}`
got := ParseJSONLLine(line, "site.yml", nil)
if len(got.Findings) != 1 {
t.Fatalf("findings = %d", len(got.Findings))
}
f := got.Findings[0]
if f.Status != "unreachable" {
t.Fatalf("status = %q", f.Status)
}
if f.Diagnostic != "Ansible bootstrap failed: cannot create remote tmp directory" {
t.Fatalf("diagnostic = %q", f.Diagnostic)
}
}
func TestParseJSONLUnreachableDiagnosticTimeoutAndAuth(t *testing.T) {
if got := DiagnoseUnreachable("ssh: connect to host 10.0.0.1 port 22: Operation timed out"); got != "SSH timed out" {
t.Fatalf("timeout diagnostic = %q", got)
}
if got := DiagnoseUnreachable("Permission denied (publickey,password)"); got != "SSH authentication or permission failed" {
t.Fatalf("auth diagnostic = %q", got)
}
}
func TestParseJSONLSkippedLoopItem(t *testing.T) {
line := `{"_event":"v2_runner_on_skipped","hosts":{"web01":{"changed":false,"item":"lightline","msg":"Conditional result was False"}},"task":{"name":"Deploy Vim plugins"}}`
got := ParseJSONLLine(line, "site.yml", []string{"vim"})
if len(got.Findings) != 1 {
t.Fatalf("findings = %d", len(got.Findings))
}
f := got.Findings[0]
if f.Status != "skipped" || f.Item != "lightline" || f.Summary != "Conditional result was False" {
t.Fatalf("unexpected finding: %+v", f)
}
}
func TestParseJSONLStats(t *testing.T) {
line := `{"_event":"v2_playbook_on_stats","stats":{"web01":{"changed":2,"failures":1,"ok":4,"skipped":3,"unreachable":0},"web02":{"changed":0,"failures":0,"ok":5,"skipped":1,"unreachable":1}}}`
got := ParseJSONLLine(line, "site.yml", nil)
if len(got.Recaps) != 2 {
t.Fatalf("recaps = %d", len(got.Recaps))
}
if got.Recaps[0].Host != "web01" || got.Recaps[0].Changed != 2 || got.Recaps[0].Failed != 1 {
t.Fatalf("first recap = %+v", got.Recaps[0])
}
if got.Recaps[1].Host != "web02" || got.Recaps[1].Unreachable != 1 {
t.Fatalf("second recap = %+v", got.Recaps[1])
}
}
func TestParseJSONLIgnoresPlainText(t *testing.T) {
got := ParseJSONL([]byte("PLAY RECAP\nnot json\n"), "site.yml", nil)
if len(got.Findings) != 0 || len(got.Recaps) != 0 {
t.Fatalf("unexpected parse result: %+v", got)
}
}
+239
View File
@@ -0,0 +1,239 @@
package compliance
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
"ansibletui/internal/ansible"
"ansibletui/internal/config"
"ansibletui/internal/inventory"
)
// Job is one compliance check command: a playbook scoped to all hosts or a group.
type Job struct {
Playbook string
Tags []string
Limit string
Group string
}
var fileNames = []string{"compliance.yaml", "compliance.yml"}
// LoadFromDir loads compliance.yaml from the playbook root when present.
func LoadFromDir(dir string) (config.Compliance, string, bool, error) {
for _, name := range fileNames {
path := filepath.Join(dir, name)
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
continue
}
if err != nil {
return config.Compliance{}, path, false, err
}
var cfg config.Compliance
if err := yaml.Unmarshal(data, &cfg); err != nil {
return config.Compliance{}, path, false, err
}
if cfg.Groups == nil {
cfg.Groups = map[string][]config.CompliancePlaybook{}
}
return cfg, path, true, nil
}
return config.Compliance{}, "", false, nil
}
// Label returns a compact display label for the job target.
func (j Job) Label() string {
if j.Group != "" {
return j.Group
}
if j.Limit != "" {
return j.Limit
}
return "all"
}
// Plan expands config mappings into deterministic check jobs.
func Plan(cfg config.Compliance, inv *inventory.Inventory) []Job {
if inv == nil {
return nil
}
knownGroups := map[string]bool{}
for _, g := range inv.Groups() {
knownGroups[g] = true
}
seen := map[string]bool{}
var jobs []Job
add := func(job Job) {
job.Playbook = strings.TrimSpace(job.Playbook)
job.Limit = strings.TrimSpace(job.Limit)
job.Group = strings.TrimSpace(job.Group)
job.Tags = cleanTags(job.Tags)
if job.Playbook == "" {
return
}
key := job.Playbook + "\x00" + strings.Join(job.Tags, ",") + "\x00" + job.Limit
if seen[key] {
return
}
seen[key] = true
jobs = append(jobs, job)
}
for _, pb := range cfg.Universal {
add(Job{Playbook: pb.Playbook, Tags: pb.Tags, Limit: "all"})
}
var groups []string
for group := range cfg.Groups {
if knownGroups[group] {
groups = append(groups, group)
}
}
sort.Strings(groups)
for _, group := range groups {
for _, pb := range cfg.Groups[group] {
add(Job{Playbook: pb.Playbook, Tags: pb.Tags, Limit: group, Group: group})
}
}
return jobs
}
func cleanTags(tags []string) []string {
if len(tags) == 0 {
return nil
}
out := make([]string, 0, len(tags))
seen := map[string]bool{}
for _, tag := range tags {
tag = strings.TrimSpace(tag)
if tag == "" || seen[tag] {
continue
}
seen[tag] = true
out = append(out, tag)
}
sort.Strings(out)
return out
}
// Summary is an aggregate view of one or more Ansible recaps.
type Summary struct {
OK int
Changed int
Failed int
Unreachable int
Skipped int
Status string
ChangedHosts []string
}
// SummarizeRecaps aggregates PLAY RECAP rows into a run summary.
func SummarizeRecaps(recaps []ansible.Recap, mode ansible.Mode) Summary {
s := Summary{Status: "ok"}
changedHosts := map[string]bool{}
for _, r := range recaps {
s.OK += r.OK
s.Changed += r.Changed
s.Failed += r.Failed
s.Unreachable += r.Unreachable
s.Skipped += r.Skipped
if r.Changed > 0 {
changedHosts[r.Host] = true
}
}
for host := range changedHosts {
s.ChangedHosts = append(s.ChangedHosts, host)
}
sort.Strings(s.ChangedHosts)
switch {
case s.Unreachable > 0:
s.Status = "unreachable"
case s.Failed > 0:
s.Status = "failed"
case mode.IsCheckMode() && s.Changed > 0:
s.Status = "drift"
case mode.IsCheckMode():
s.Status = "clean"
default:
s.Status = "ok"
}
return s
}
// ApplyRecaps updates runtime host state from check/apply recap rows.
//
// When recaps contain multiple entries for the same host (e.g. one per
// playbook in a parallel compliance scan), counters are accumulated before
// interpreting status. This means any failure or change from ANY playbook
// is preserved — the worst-case state wins rather than the last-writer.
func ApplyRecaps(inv *inventory.Inventory, recaps []ansible.Recap, mode ansible.Mode, checkedAt time.Time) {
if inv == nil {
return
}
// Accumulate per-host so multiple recaps for the same host are merged.
byHost := map[string]ansible.Recap{}
for _, r := range recaps {
if existing, ok := byHost[r.Host]; ok {
r = ansible.Recap{
Host: r.Host,
OK: existing.OK + r.OK,
Changed: existing.Changed + r.Changed,
Failed: existing.Failed + r.Failed,
Unreachable: existing.Unreachable + r.Unreachable,
Skipped: existing.Skipped + r.Skipped,
}
}
byHost[r.Host] = r
}
ts := checkedAt.Format("15:04")
for _, host := range inv.Hosts {
r, ok := byHost[host.Name]
if !ok {
continue
}
status := r.InterpretStatus(mode)
if status == "ok" {
status = "clean"
}
host.DriftState = status
if mode.IsCheckMode() {
host.LastCheck = ts
} else if status == "clean" {
host.LastApply = ts
}
}
}
// ProgressBar renders a stable ASCII progress bar.
func ProgressBar(done, total, width int) string {
if width < 1 {
return ""
}
if total <= 0 {
return strings.Repeat("-", width)
}
if done < 0 {
done = 0
}
if done > total {
done = total
}
filled := done * width / total
return strings.Repeat("#", filled) + strings.Repeat("-", width-filled)
}
// RecapLine returns a compact counter line for dashboard panels.
func (s Summary) RecapLine() string {
return fmt.Sprintf("ok=%d changed=%d failed=%d unreachable=%d skipped=%d", s.OK, s.Changed, s.Failed, s.Unreachable, s.Skipped)
}
+252
View File
@@ -0,0 +1,252 @@
package compliance
import (
"os"
"path/filepath"
"reflect"
"testing"
"time"
"ansibletui/internal/ansible"
"ansibletui/internal/config"
"ansibletui/internal/inventory"
)
func TestPlanUniversalAndGroups(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", Group: "web"},
{Name: "db01", Group: "db"},
}}
cfg := config.Compliance{
Universal: []config.CompliancePlaybook{{Playbook: "site.yml"}, {Playbook: "site.yml"}},
Groups: map[string][]config.CompliancePlaybook{
"web": []config.CompliancePlaybook{{Playbook: "services/nginx.yml", Tags: []string{"nginx"}}},
"missing": []config.CompliancePlaybook{{Playbook: "ignored.yml"}},
},
}
got := Plan(cfg, inv)
want := []Job{
{Playbook: "site.yml", Limit: "all"},
{Playbook: "services/nginx.yml", Tags: []string{"nginx"}, Limit: "web", Group: "web"},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("got %#v want %#v", got, want)
}
}
func TestPlanEmpty(t *testing.T) {
got := Plan(config.Compliance{}, &inventory.Inventory{})
if len(got) != 0 {
t.Fatalf("got %#v", got)
}
}
func TestLoadFromDir(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "compliance.yaml")
if err := os.WriteFile(path, []byte(`
universal:
- site.yml
groups:
dns:
- services/pi-hole.yml
`), 0o644); err != nil {
t.Fatal(err)
}
got, source, ok, err := LoadFromDir(dir)
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected compliance file")
}
if source != path {
t.Fatalf("source = %q want %q", source, path)
}
if !reflect.DeepEqual(got.Universal, []config.CompliancePlaybook{{Playbook: "site.yml"}}) {
t.Fatalf("universal = %#v", got.Universal)
}
if !reflect.DeepEqual(got.Groups["dns"], []config.CompliancePlaybook{{Playbook: "services/pi-hole.yml"}}) {
t.Fatalf("groups = %#v", got.Groups)
}
}
func TestSummarizeRecaps(t *testing.T) {
recaps := []ansible.Recap{
{Host: "web01", OK: 4, Changed: 2},
{Host: "web02", OK: 4},
}
got := SummarizeRecaps(recaps, ansible.ModeCheckDiff)
if got.Status != "drift" || got.Changed != 2 || len(got.ChangedHosts) != 1 || got.ChangedHosts[0] != "web01" {
t.Fatalf("unexpected summary: %#v", got)
}
}
func TestSummarizeRecapsFailurePriority(t *testing.T) {
recaps := []ansible.Recap{
{Host: "web01", OK: 4, Changed: 2},
{Host: "web02", Unreachable: 1},
{Host: "web03", Failed: 1},
}
got := SummarizeRecaps(recaps, ansible.ModeCheckDiff)
if got.Status != "unreachable" {
t.Fatalf("got %q", got.Status)
}
}
func TestApplyRecapsUpdatesHosts(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", DriftState: "unknown"},
{Name: "web02", DriftState: "unknown"},
}}
ApplyRecaps(inv, []ansible.Recap{
{Host: "web01", OK: 2, Changed: 1},
{Host: "web02", OK: 2},
}, ansible.ModeCheckDiff, time.Date(2026, 5, 23, 9, 30, 0, 0, time.UTC))
if inv.Hosts[0].DriftState != "drift" || inv.Hosts[0].LastCheck != "09:30" {
t.Fatalf("web01 not drifted: %#v", inv.Hosts[0])
}
if inv.Hosts[1].DriftState != "clean" || inv.Hosts[1].LastCheck != "09:30" {
t.Fatalf("web02 not clean: %#v", inv.Hosts[1])
}
}
// ---- ApplyRecaps accumulation (regression tests for last-writer-wins bug) ----
// Regression: when the same host appears in recaps from two different playbooks,
// the counters must be accumulated so the worst-case state wins — not the
// state from whichever playbook happened to finish last.
// site.yml → web01 changed=1 (drift)
// xcp-guest-tools → web01 failed=1
// Combined: changed=1 failed=1 → "failed" (not "drift")
func TestApplyRecapsAccumulatesSameHost(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", DriftState: "unknown"},
}}
recaps := []ansible.Recap{
{Host: "web01", OK: 4, Changed: 1},
{Host: "web01", OK: 2, Failed: 1},
}
ApplyRecaps(inv, recaps, ansible.ModeCheckDiff, time.Now())
if inv.Hosts[0].DriftState != "failed" {
t.Errorf("want 'failed' (worst-case), got %q", inv.Hosts[0].DriftState)
}
}
// clean from one playbook must not silently overwrite failed from another.
func TestApplyRecapsCleanPlusFailedIsFailed(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", DriftState: "unknown"},
}}
recaps := []ansible.Recap{
{Host: "web01", OK: 3}, // clean
{Host: "web01", OK: 1, Failed: 1}, // failed
}
ApplyRecaps(inv, recaps, ansible.ModeCheckDiff, time.Now())
if inv.Hosts[0].DriftState != "failed" {
t.Errorf("want 'failed', got %q", inv.Hosts[0].DriftState)
}
}
// drift from one playbook must not be erased by clean from another.
func TestApplyRecapsDriftPlusCleanIsDrift(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", DriftState: "unknown"},
}}
recaps := []ansible.Recap{
{Host: "web01", OK: 4, Changed: 1}, // drift
{Host: "web01", OK: 2}, // clean
}
ApplyRecaps(inv, recaps, ansible.ModeCheckDiff, time.Now())
if inv.Hosts[0].DriftState != "drift" {
t.Errorf("want 'drift', got %q", inv.Hosts[0].DriftState)
}
}
// A host not mentioned in the recaps must keep its existing DriftState.
func TestApplyRecapsHostNotInRecapsPreservesState(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", DriftState: "drift"},
{Name: "other", DriftState: "clean"},
}}
// Recaps only mention "other".
ApplyRecaps(inv, []ansible.Recap{{Host: "other", OK: 2}}, ansible.ModeCheckDiff, time.Now())
if inv.Hosts[0].DriftState != "drift" {
t.Errorf("web01 state should be unchanged, got %q", inv.Hosts[0].DriftState)
}
}
// Empty recaps must not panic or modify any host.
func TestApplyRecapsEmptyRecapsNoChange(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{
{Name: "web01", DriftState: "drift"},
}}
ApplyRecaps(inv, nil, ansible.ModeCheckDiff, time.Now())
if inv.Hosts[0].DriftState != "drift" {
t.Errorf("host state changed by empty recaps: %q", inv.Hosts[0].DriftState)
}
}
// ---- SummarizeRecaps priority ----
func TestSummarizeRecapsPriorityUnreachableWins(t *testing.T) {
// unreachable > failed > drift > clean
recaps := []ansible.Recap{
{Host: "a", Unreachable: 1},
{Host: "b", Failed: 1},
{Host: "c", Changed: 1},
{Host: "d", OK: 2},
}
got := SummarizeRecaps(recaps, ansible.ModeCheckDiff)
if got.Status != "unreachable" {
t.Errorf("want 'unreachable', got %q", got.Status)
}
}
func TestSummarizeRecapsPriorityFailedOverDrift(t *testing.T) {
recaps := []ansible.Recap{
{Host: "a", Failed: 1},
{Host: "b", Changed: 1},
}
got := SummarizeRecaps(recaps, ansible.ModeCheckDiff)
if got.Status != "failed" {
t.Errorf("want 'failed', got %q", got.Status)
}
}
func TestSummarizeRecapsPriorityDriftOverClean(t *testing.T) {
recaps := []ansible.Recap{
{Host: "a", Changed: 1},
{Host: "b", OK: 2},
}
got := SummarizeRecaps(recaps, ansible.ModeCheckDiff)
if got.Status != "drift" {
t.Errorf("want 'drift', got %q", got.Status)
}
}
func TestSummarizeRecapsAllClean(t *testing.T) {
recaps := []ansible.Recap{
{Host: "a", OK: 3},
{Host: "b", OK: 5},
}
got := SummarizeRecaps(recaps, ansible.ModeCheckDiff)
if got.Status != "clean" {
t.Errorf("want 'clean', got %q", got.Status)
}
}
// In apply mode, changed tasks mean a successful apply — not drift.
func TestSummarizeRecapsApplyModeChangedIsOK(t *testing.T) {
recaps := []ansible.Recap{
{Host: "web01", OK: 3, Changed: 2},
}
got := SummarizeRecaps(recaps, ansible.ModeApply)
if got.Status != "ok" {
t.Errorf("apply mode with changes: want 'ok', got %q", got.Status)
}
}
+465 -32
View File
@@ -1,20 +1,114 @@
package config
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
"ansibletui/internal/git"
)
type Config struct {
PlaybookDir string `yaml:"playbook_dir"`
InventoryPath string `yaml:"inventory_path"`
Runtime string `yaml:"runtime"`
RecentPlaybook string `yaml:"recent_playbook"`
const (
configFileName = "ansibletui.yaml"
legacyFileName = "config.yaml"
defaultBranch = "main"
onMissingClone = "clone"
onMissingInit = "init"
)
// GitRepo configures an optional git-backed directory.
type GitRepo struct {
Enabled bool `yaml:"enabled"`
Remote string `yaml:"remote,omitempty"`
Branch string `yaml:"branch,omitempty"`
Path string `yaml:"path,omitempty"`
File string `yaml:"file,omitempty"` // inventory: path relative to Path
OnMissing string `yaml:"on_missing,omitempty"`
SyncOnStartup bool `yaml:"sync_on_startup"`
}
func AppDir() (string, error) {
// Compliance configures playbooks that participate in dashboard drift checks.
type Compliance struct {
Universal []CompliancePlaybook `yaml:"universal,omitempty"`
Groups map[string][]CompliancePlaybook `yaml:"groups,omitempty"`
}
// CompliancePlaybook maps a playbook into compliance scans. It supports both
// legacy scalar YAML ("site.yml") and structured YAML ({playbook, tags}).
type CompliancePlaybook struct {
Playbook string `yaml:"playbook" json:"playbook"`
Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
}
func (p CompliancePlaybook) String() string {
if len(p.Tags) == 0 {
return p.Playbook
}
return p.Playbook + " --tags " + strings.Join(p.Tags, ",")
}
func (p *CompliancePlaybook) UnmarshalYAML(value *yaml.Node) error {
switch value.Kind {
case yaml.ScalarNode:
p.Playbook = strings.TrimSpace(value.Value)
p.Tags = nil
return nil
case yaml.MappingNode:
type alias CompliancePlaybook
var a alias
if err := value.Decode(&a); err != nil {
return err
}
a.Playbook = strings.TrimSpace(a.Playbook)
for i := range a.Tags {
a.Tags[i] = strings.TrimSpace(a.Tags[i])
}
*p = CompliancePlaybook(a)
return nil
default:
return fmt.Errorf("compliance playbook must be a string or mapping")
}
}
func (p *CompliancePlaybook) UnmarshalJSON(data []byte) error {
var scalar string
if err := json.Unmarshal(data, &scalar); err == nil {
p.Playbook = strings.TrimSpace(scalar)
p.Tags = nil
return nil
}
type alias CompliancePlaybook
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
a.Playbook = strings.TrimSpace(a.Playbook)
for i := range a.Tags {
a.Tags[i] = strings.TrimSpace(a.Tags[i])
}
*p = CompliancePlaybook(a)
return nil
}
// Config is the application configuration.
type Config struct {
PlaybookDir string `yaml:"playbook_dir"`
InventoryPath string `yaml:"inventory_path"`
Runtime string `yaml:"runtime"`
RecentPlaybook string `yaml:"recent_playbook"`
PlaybooksGit *GitRepo `yaml:"playbooks_git,omitempty"`
InventoryGit *GitRepo `yaml:"inventory_git,omitempty"`
Compliance Compliance `yaml:"compliance,omitempty"`
}
// ConfigDir returns ~/.config/ansibletui (XDG config).
func ConfigDir() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
@@ -22,56 +116,395 @@ func AppDir() (string, error) {
return filepath.Join(dir, "ansibletui"), nil
}
func Load() (*Config, error) {
dir, err := AppDir()
// AppDir is an alias for ConfigDir (legacy name).
func AppDir() (string, error) {
return ConfigDir()
}
// ConfigPath returns the canonical config file path.
func ConfigPath() (string, error) {
dir, err := ConfigDir()
if err != nil {
return defaults(""), nil
return "", err
}
path := filepath.Join(dir, "config.yaml")
return filepath.Join(dir, configFileName), nil
}
// DataDir returns ~/.ansibletui for repos, inventory, and run history.
func DataDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, ".ansibletui"), nil
}
// RunsDir returns ~/.ansibletui/runs.
func RunsDir() (string, error) {
data, err := DataDir()
if err != nil {
return "", err
}
return filepath.Join(data, "runs"), nil
}
// Load reads config, migrates legacy files on first run, and normalizes paths.
func Load() (*Config, error) {
if err := migrateIfNeeded(); err != nil {
return nil, err
}
path, err := ConfigPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return defaults(dir), nil
cfg := freshDefaults()
cfg.Normalize()
if err := cfg.Save(); err != nil {
return nil, err
}
return cfg, nil
}
if err != nil {
return nil, err
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
if cfg.InventoryPath == "" {
cfg.InventoryPath = filepath.Join(dir, "inventory.yml")
}
if cfg.Runtime == "" {
cfg.Runtime = "auto"
}
cfg.Normalize()
return &cfg, nil
}
func migrateIfNeeded() error {
path, err := ConfigPath()
if err != nil {
return err
}
if _, err := os.Stat(path); err == nil {
return migrateRunsDir()
} else if !os.IsNotExist(err) {
return err
}
cfgDir, err := ConfigDir()
if err != nil {
return err
}
legacyPath := filepath.Join(cfgDir, legacyFileName)
var cfg *Config
if data, err := os.ReadFile(legacyPath); err == nil {
var c Config
if err := yaml.Unmarshal(data, &c); err != nil {
return fmt.Errorf("legacy config: %w", err)
}
cfg = &c
_ = os.Rename(legacyPath, legacyPath+".migrated")
} else if os.IsNotExist(err) {
cfg = freshDefaults()
} else {
return err
}
cfg.Normalize()
if err := os.MkdirAll(cfgDir, 0o755); err != nil {
return err
}
if err := cfg.Save(); err != nil {
return err
}
return migrateRunsDir()
}
func migrateRunsDir() error {
newRuns, err := RunsDir()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(newRuns), 0o755); err != nil {
return err
}
entries, err := os.ReadDir(newRuns)
if err != nil && !os.IsNotExist(err) {
return err
}
if err == nil && len(entries) > 0 {
return nil
}
cfgDir, err := ConfigDir()
if err != nil {
return err
}
oldRuns := filepath.Join(cfgDir, "runs")
oldEntries, err := os.ReadDir(oldRuns)
if err != nil {
if os.IsNotExist(err) {
return os.MkdirAll(newRuns, 0o755)
}
return err
}
if len(oldEntries) == 0 {
return os.MkdirAll(newRuns, 0o755)
}
if err := os.MkdirAll(newRuns, 0o755); err != nil {
return err
}
for _, e := range oldEntries {
oldPath := filepath.Join(oldRuns, e.Name())
newPath := filepath.Join(newRuns, e.Name())
if err := os.Rename(oldPath, newPath); err != nil {
return fmt.Errorf("migrate runs %q: %w", e.Name(), err)
}
}
return nil
}
func freshDefaults() *Config {
data, _ := DataDir()
return &Config{
PlaybookDir: filepath.Join(data, "playbooks"),
InventoryPath: filepath.Join(data, "inventory", "inventory.yml"),
Runtime: "auto",
PlaybooksGit: &GitRepo{
Enabled: false,
Path: filepath.Join(data, "playbooks"),
Branch: defaultBranch,
OnMissing: onMissingClone,
SyncOnStartup: true,
},
InventoryGit: &GitRepo{
Enabled: false,
Path: filepath.Join(data, "inventory"),
File: "inventory.yml",
Branch: defaultBranch,
OnMissing: onMissingInit,
SyncOnStartup: true,
},
}
}
// Normalize expands paths, fills defaults, and ensures data directories exist.
func (c *Config) Normalize() {
data, _ := DataDir()
if c.PlaybooksGit == nil {
c.PlaybooksGit = &GitRepo{
Path: filepath.Join(data, "playbooks"),
Branch: defaultBranch,
OnMissing: onMissingClone,
SyncOnStartup: true,
}
}
if c.InventoryGit == nil {
c.InventoryGit = &GitRepo{
Path: filepath.Join(data, "inventory"),
File: "inventory.yml",
Branch: defaultBranch,
OnMissing: onMissingInit,
SyncOnStartup: true,
}
}
c.PlaybookDir = expandPath(c.PlaybookDir)
c.InventoryPath = expandPath(c.InventoryPath)
normalizeGitRepo(c.PlaybooksGit, filepath.Join(data, "playbooks"), onMissingClone, "")
normalizeGitRepo(c.InventoryGit, filepath.Join(data, "inventory"), onMissingInit, "inventory.yml")
if c.Runtime == "" {
c.Runtime = "auto"
}
if c.Compliance.Groups == nil {
c.Compliance.Groups = map[string][]CompliancePlaybook{}
}
if c.InventoryPath == "" {
c.InventoryPath = c.EffectiveInventoryPath()
}
_ = os.MkdirAll(data, 0o755)
// Do not mkdir git clone roots before sync — empty dirs block clone.
if c.PlaybooksGit == nil || !c.PlaybooksGit.Enabled {
_ = os.MkdirAll(c.EffectivePlaybookDir(), 0o755)
}
if c.InventoryGit == nil || !c.InventoryGit.Enabled {
invDir := filepath.Dir(c.EffectiveInventoryPath())
if invDir != "" && invDir != "." {
_ = os.MkdirAll(invDir, 0o755)
}
}
runs, _ := RunsDir()
_ = os.MkdirAll(runs, 0o755)
}
func normalizeGitRepo(r *GitRepo, defaultPath, defaultOnMissing, defaultFile string) {
if r.Path == "" {
r.Path = defaultPath
}
r.Path = expandPath(r.Path)
if r.Branch == "" {
r.Branch = defaultBranch
}
if r.OnMissing == "" {
r.OnMissing = defaultOnMissing
}
r.OnMissing = strings.ToLower(r.OnMissing)
if defaultFile != "" && r.File == "" {
r.File = defaultFile
}
}
func expandPath(p string) string {
if p == "" {
return p
}
if strings.HasPrefix(p, "~/") {
home, err := os.UserHomeDir()
if err != nil {
return p
}
return filepath.Join(home, p[2:])
}
if p == "~" {
home, _ := os.UserHomeDir()
return home
}
return p
}
// EffectivePlaybookDir returns the directory used to discover playbooks.
func (c *Config) EffectivePlaybookDir() string {
if c.PlaybooksGit != nil && c.PlaybooksGit.Enabled && c.PlaybooksGit.Path != "" {
return c.PlaybooksGit.Path
}
if c.PlaybookDir != "" {
return c.PlaybookDir
}
data, _ := DataDir()
return filepath.Join(data, "playbooks")
}
// EffectiveInventoryPath returns the inventory file path.
func (c *Config) EffectiveInventoryPath() string {
if c.InventoryGit != nil && c.InventoryGit.Enabled && c.InventoryGit.Path != "" {
file := c.InventoryGit.File
if file == "" {
file = "inventory.yml"
}
return filepath.Join(c.InventoryGit.Path, file)
}
if c.InventoryPath != "" {
return c.InventoryPath
}
data, _ := DataDir()
return filepath.Join(data, "inventory", "inventory.yml")
}
// SyncGitRepos runs startup sync for enabled repos with sync_on_startup.
func (c *Config) SyncGitRepos(ctx context.Context) []error {
var errs []error
if c.PlaybooksGit != nil && c.PlaybooksGit.Enabled && c.PlaybooksGit.SyncOnStartup {
if _, err := c.syncPlaybooks(ctx); err != nil {
errs = append(errs, fmt.Errorf("playbooks git: %w", err))
}
}
if c.InventoryGit != nil && c.InventoryGit.Enabled && c.InventoryGit.SyncOnStartup {
if _, err := c.syncInventory(ctx); err != nil {
errs = append(errs, fmt.Errorf("inventory git: %w", err))
}
}
return errs
}
// SyncPlaybooks pulls or bootstraps the playbooks repo.
func (c *Config) SyncPlaybooks(ctx context.Context) (string, error) {
return c.syncPlaybooks(ctx)
}
// SyncInventory pulls or bootstraps the inventory repo.
func (c *Config) SyncInventory(ctx context.Context) (string, error) {
return c.syncInventory(ctx)
}
func (c *Config) syncPlaybooks(ctx context.Context) (string, error) {
r := c.PlaybooksGit
if r == nil {
return "", fmt.Errorf("playbooks_git is not configured")
}
if r.Remote == "" {
return "", fmt.Errorf("playbooks_git.remote is required")
}
return git.Sync(ctx, git.SyncOptions{
Path: r.Path,
Remote: r.Remote,
Branch: r.Branch,
OnMissing: r.OnMissing,
})
}
func (c *Config) syncInventory(ctx context.Context) (string, error) {
r := c.InventoryGit
if r == nil {
return "", fmt.Errorf("inventory_git is not configured")
}
if r.Remote == "" {
return "", fmt.Errorf("inventory_git.remote is required")
}
invPath := c.EffectiveInventoryPath()
opts := git.SyncOptions{
Path: r.Path,
Remote: r.Remote,
Branch: r.Branch,
OnMissing: r.OnMissing,
SeedFilePath: invPath,
SeedFileContent: emptyInventoryYAML(),
}
return git.Sync(ctx, opts)
}
// PushPlaybooks pushes the playbooks repo to its remote.
func (c *Config) PushPlaybooks(ctx context.Context) (string, error) {
r := c.PlaybooksGit
if r == nil || !r.Enabled {
return "", fmt.Errorf("playbooks git is not enabled")
}
return git.Push(ctx, r.Path, r.Branch)
}
// PushInventory pushes the inventory repo to its remote.
func (c *Config) PushInventory(ctx context.Context) (string, error) {
r := c.InventoryGit
if r == nil || !r.Enabled {
return "", fmt.Errorf("inventory git is not enabled")
}
return git.Push(ctx, r.Path, r.Branch)
}
func emptyInventoryYAML() []byte {
return []byte("all:\n hosts: {}\n children: {}\n")
}
// Save writes the config to ansibletui.yaml.
func (c *Config) Save() error {
dir, err := AppDir()
dir, err := ConfigDir()
if err != nil {
return err
}
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
path, err := ConfigPath()
if err != nil {
return err
}
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(dir, "config.yaml"), data, 0o644)
}
func defaults(dir string) *Config {
home, _ := os.UserHomeDir()
if dir == "" {
cfgDir, _ := os.UserConfigDir()
dir = filepath.Join(cfgDir, "ansibletui")
}
return &Config{
PlaybookDir: filepath.Join(home, "homelab", "ansible"),
InventoryPath: filepath.Join(dir, "inventory.yml"),
Runtime: "auto",
}
return os.WriteFile(path, data, 0o644)
}
+46
View File
@@ -0,0 +1,46 @@
package config
import (
"reflect"
"testing"
"gopkg.in/yaml.v3"
)
func TestComplianceConfigLoads(t *testing.T) {
data := []byte(`
playbook_dir: /tmp/playbooks
inventory_path: /tmp/inventory.yml
compliance:
universal:
- site.yml
- playbook: tagged.yml
tags: [sudo, packages]
groups:
web:
- services/nginx.yml
`)
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatal(err)
}
cfg.Normalize()
if !reflect.DeepEqual(cfg.Compliance.Universal, []CompliancePlaybook{
{Playbook: "site.yml"},
{Playbook: "tagged.yml", Tags: []string{"sudo", "packages"}},
}) {
t.Fatalf("universal = %#v", cfg.Compliance.Universal)
}
if !reflect.DeepEqual(cfg.Compliance.Groups["web"], []CompliancePlaybook{{Playbook: "services/nginx.yml"}}) {
t.Fatalf("groups = %#v", cfg.Compliance.Groups)
}
}
func TestComplianceGroupsDefaulted(t *testing.T) {
cfg := Config{}
cfg.Normalize()
if cfg.Compliance.Groups == nil {
t.Fatal("Compliance.Groups was nil")
}
}
+36
View File
@@ -0,0 +1,36 @@
package git
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Run executes git in dir with the given arguments.
func Run(ctx context.Context, dir string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, "git", args...)
if dir != "" {
cmd.Dir = dir
}
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
if err := cmd.Run(); err != nil {
out := strings.TrimSpace(buf.String())
if out != "" {
return out, fmt.Errorf("%w: %s", err, out)
}
return "", err
}
return strings.TrimSpace(buf.String()), nil
}
// IsRepo reports whether path is a git repository root.
func IsRepo(path string) bool {
_, err := os.Stat(filepath.Join(path, ".git"))
return err == nil
}
+24
View File
@@ -0,0 +1,24 @@
package git
import (
"context"
"fmt"
)
// Push pushes the current branch to origin.
func Push(ctx context.Context, dir, branch string) (string, error) {
if !IsRepo(dir) {
return "", fmt.Errorf("%s is not a git repository", dir)
}
if branch == "" {
branch = "main"
}
out, err := Run(ctx, dir, "push", "origin", branch)
if err != nil {
return "", err
}
if out == "" {
return "pushed", nil
}
return out, nil
}
+31
View File
@@ -0,0 +1,31 @@
package git
import (
"context"
"fmt"
"strings"
)
// ShortStatus returns a one-line branch and cleanliness summary.
func ShortStatus(ctx context.Context, dir string) string {
if !IsRepo(dir) {
return "not a git repo"
}
branch, err := Run(ctx, dir, "rev-parse", "--abbrev-ref", "HEAD")
if err != nil {
return "unknown branch"
}
dirty, err := Run(ctx, dir, "status", "--porcelain")
if err != nil {
return branch
}
if strings.TrimSpace(dirty) == "" {
return branch + " (clean)"
}
return branch + " (dirty)"
}
// StatusLine formats repo name with status for the UI.
func StatusLine(ctx context.Context, name, dir string) string {
return fmt.Sprintf("%s: %s", name, ShortStatus(ctx, dir))
}
+155
View File
@@ -0,0 +1,155 @@
package git
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
)
// SyncOptions configures clone, init, or pull for a repository.
type SyncOptions struct {
Path string
Remote string
Branch string
OnMissing string // clone | init
SeedFilePath string // optional file to create on init (inventory)
SeedFileContent []byte
}
// Sync clones, initializes, or pulls the repository at opts.Path.
func Sync(ctx context.Context, opts SyncOptions) (string, error) {
path := opts.Path
branch := opts.Branch
if branch == "" {
branch = "main"
}
onMissing := strings.ToLower(opts.OnMissing)
if onMissing == "" {
onMissing = "clone"
}
info, err := os.Stat(path)
if os.IsNotExist(err) {
return syncMissing(ctx, opts, branch, onMissing)
}
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("%s exists but is not a directory", path)
}
if !IsRepo(path) {
if onMissing == "clone" && dirIsEmptyOrPlaceholder(path) {
if err := os.RemoveAll(path); err != nil {
return "", fmt.Errorf("remove empty path before clone: %w", err)
}
return cloneRepo(ctx, opts.Remote, branch, path)
}
return "", fmt.Errorf("%s exists but is not a git repository (remove it or use a different path)", path)
}
if _, err := Run(ctx, path, "fetch", "origin"); err != nil {
return "", err
}
out, err := Run(ctx, path, "pull", "--ff-only", "origin", branch)
if err != nil {
return "", err
}
if out == "" {
return "already up to date", nil
}
return out, nil
}
func syncMissing(ctx context.Context, opts SyncOptions, branch, onMissing string) (string, error) {
switch onMissing {
case "init":
return bootstrap(ctx, opts, branch)
case "clone":
return cloneRepo(ctx, opts.Remote, branch, opts.Path)
default:
return "", fmt.Errorf("unknown on_missing %q (use clone or init)", onMissing)
}
}
// dirIsEmptyOrPlaceholder reports whether dir has no files or only placeholder dotfiles.
func dirIsEmptyOrPlaceholder(dir string) bool {
entries, err := os.ReadDir(dir)
if err != nil {
return false
}
for _, e := range entries {
name := e.Name()
if name == ".DS_Store" || name == ".gitkeep" {
continue
}
return false
}
return true
}
func cloneRepo(ctx context.Context, remote, branch, path string) (string, error) {
parent := filepath.Dir(path)
if parent != "" && parent != "." {
if err := os.MkdirAll(parent, 0o755); err != nil {
return "", err
}
}
out, err := Run(ctx, "", "clone", "--branch", branch, remote, path)
if err != nil && strings.Contains(err.Error(), "not found in upstream") {
// Branch may not exist; try default clone then checkout.
if rmErr := os.RemoveAll(path); rmErr != nil && !os.IsNotExist(rmErr) {
return "", err
}
out, err = Run(ctx, "", "clone", remote, path)
if err != nil {
return "", err
}
if _, coErr := Run(ctx, path, "checkout", branch); coErr != nil {
return out, fmt.Errorf("cloned but checkout %s failed: %w", branch, coErr)
}
return "cloned (default branch), checked out " + branch, nil
}
return out, err
}
func bootstrap(ctx context.Context, opts SyncOptions, branch string) (string, error) {
path := opts.Path
if err := os.MkdirAll(path, 0o755); err != nil {
return "", err
}
if opts.SeedFilePath != "" && len(opts.SeedFileContent) > 0 {
if _, err := os.Stat(opts.SeedFilePath); os.IsNotExist(err) {
if err := os.MkdirAll(filepath.Dir(opts.SeedFilePath), 0o755); err != nil {
return "", err
}
if err := os.WriteFile(opts.SeedFilePath, opts.SeedFileContent, 0o644); err != nil {
return "", err
}
}
}
if _, err := Run(ctx, path, "init", "-b", branch); err != nil {
return "", err
}
if _, err := Run(ctx, path, "remote", "add", "origin", opts.Remote); err != nil {
return "", err
}
if _, err := Run(ctx, path, "add", "."); err != nil {
return "", err
}
status, _ := Run(ctx, path, "status", "--porcelain")
if strings.TrimSpace(status) != "" {
if _, err := Run(ctx, path, "commit", "-m", "Initial commit"); err != nil {
return "", err
}
}
out, err := Run(ctx, path, "push", "-u", "origin", branch)
if err != nil {
return "", fmt.Errorf("init ok but push failed (commit locally?): %w", err)
}
return "initialized and pushed: " + out, nil
}
+58 -14
View File
@@ -8,24 +8,29 @@ import (
"sort"
"strings"
"time"
"ansibletui/internal/ansible"
)
// RunRecord stores metadata about a single check/apply execution.
type RunRecord struct {
ID string `json:"id"`
Playbook string `json:"playbook"`
Host string `json:"host"`
Mode string `json:"mode"`
Status string `json:"status"` // "clean","drift","failed","unreachable","ok"
OK int `json:"ok"`
Changed int `json:"changed"`
Failed int `json:"failed"`
Unreachable int `json:"unreachable"`
Skipped int `json:"skipped"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
LogFile string `json:"log_file"`
ExitCode int `json:"exit_code"`
ID string `json:"id"`
Playbook string `json:"playbook"`
Host string `json:"host"`
Mode string `json:"mode"`
Status string `json:"status"` // "clean","drift","failed","unreachable","ok"
OK int `json:"ok"`
Changed int `json:"changed"`
Failed int `json:"failed"`
Unreachable int `json:"unreachable"`
Skipped int `json:"skipped"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
LogFile string `json:"log_file"`
FindingsFile string `json:"findings_file,omitempty"`
Tags []string `json:"tags,omitempty"`
DriftCount int `json:"drift_count,omitempty"`
ExitCode int `json:"exit_code"`
}
// TimeLabel returns HH:MM for display.
@@ -35,8 +40,15 @@ func (r *RunRecord) TimeLabel() string {
// StatusSummary returns a short run status label for display.
func (r *RunRecord) StatusSummary() string {
isCheck := r.Mode == "check" || r.Mode == "check+diff"
switch r.Status {
case "drift":
if isCheck {
if r.Changed > 0 {
return fmt.Sprintf("would change=%d", r.Changed)
}
return "would change"
}
if r.Changed > 0 {
return fmt.Sprintf("changed=%d", r.Changed)
}
@@ -68,6 +80,11 @@ func New(dir string) *History {
// Save writes the run record JSON and its log file to disk.
func (h *History) Save(rec *RunRecord, log []byte) error {
return h.SaveWithFindings(rec, log, nil)
}
// SaveWithFindings writes the run record, raw log, and optional finding data.
func (h *History) SaveWithFindings(rec *RunRecord, log []byte, findings []ansible.Finding) error {
if err := os.MkdirAll(h.dir, 0o755); err != nil {
return err
}
@@ -81,6 +98,17 @@ func (h *History) Save(rec *RunRecord, log []byte) error {
if err := os.WriteFile(filepath.Join(h.dir, logName), log, 0o644); err != nil {
return err
}
if len(findings) > 0 {
findingsName := fmt.Sprintf("%s-%s-%s-findings.json", rec.ID, sanitize(rec.Host), rec.Mode)
rec.FindingsFile = findingsName
data, err := json.MarshalIndent(findings, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(h.dir, findingsName), data, 0o644); err != nil {
return err
}
}
data, err := json.MarshalIndent(rec, "", " ")
if err != nil {
@@ -98,6 +126,22 @@ func (h *History) LoadLog(rec *RunRecord) ([]byte, error) {
return os.ReadFile(filepath.Join(h.dir, rec.LogFile))
}
// LoadFindings reads structured drift findings for a record.
func (h *History) LoadFindings(rec *RunRecord) ([]ansible.Finding, error) {
if rec.FindingsFile == "" {
return nil, nil
}
data, err := os.ReadFile(filepath.Join(h.dir, rec.FindingsFile))
if err != nil {
return nil, err
}
var findings []ansible.Finding
if err := json.Unmarshal(data, &findings); err != nil {
return nil, err
}
return findings, nil
}
// List returns up to n run records, newest first.
func (h *History) List(n int) ([]*RunRecord, error) {
entries, err := os.ReadDir(h.dir)
+216
View File
@@ -0,0 +1,216 @@
package history
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"ansibletui/internal/ansible"
)
func makeRecord(id, host, mode, status string, start time.Time) *RunRecord {
return &RunRecord{
ID: id,
Playbook: "site.yml",
Host: host,
Mode: mode,
Status: status,
OK: 4,
Changed: 2,
Failed: 0,
StartTime: start,
EndTime: start.Add(5 * time.Second),
ExitCode: 0,
}
}
func TestSaveCreatesJsonAndLog(t *testing.T) {
h := New(t.TempDir())
rec := makeRecord("20260523T120000", "web01", "check+diff", "drift", time.Now())
log := []byte("PLAY RECAP\nweb01 : ok=4 changed=2\n")
if err := h.Save(rec, log); err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(h.dir)
if err != nil {
t.Fatal(err)
}
var jsonFiles, logFiles int
for _, e := range entries {
switch filepath.Ext(e.Name()) {
case ".json":
jsonFiles++
case ".log":
logFiles++
}
}
if jsonFiles != 1 || logFiles != 1 {
t.Errorf("want 1 json + 1 log, got %d json + %d log", jsonFiles, logFiles)
}
// LogFile field must be populated.
if rec.LogFile == "" {
t.Error("rec.LogFile not set after Save")
}
}
func TestListNewestFirst(t *testing.T) {
h := New(t.TempDir())
base := time.Date(2026, 5, 23, 10, 0, 0, 0, time.UTC)
for i, id := range []string{"A", "B", "C"} {
rec := makeRecord(id, "web01", "check+diff", "drift", base.Add(time.Duration(i)*time.Hour))
if err := h.Save(rec, nil); err != nil {
t.Fatal(err)
}
}
records, err := h.List(0)
if err != nil {
t.Fatal(err)
}
if len(records) != 3 {
t.Fatalf("want 3 records, got %d", len(records))
}
// Newest (C = base+2h) should be first.
if records[0].ID != "C" || records[2].ID != "A" {
t.Errorf("order wrong: %v %v %v", records[0].ID, records[1].ID, records[2].ID)
}
}
func TestListCapsAtN(t *testing.T) {
h := New(t.TempDir())
base := time.Now()
for i := 0; i < 5; i++ {
// Use explicit unique IDs so concurrent saves don't overwrite each other
// (ID defaults to time.Now() format which collides within the same second).
id := fmt.Sprintf("cap%02d", i)
rec := makeRecord(id, "web01", "check+diff", "clean", base.Add(time.Duration(i)*time.Minute))
if err := h.Save(rec, nil); err != nil {
t.Fatal(err)
}
}
records, err := h.List(3)
if err != nil {
t.Fatal(err)
}
if len(records) != 3 {
t.Fatalf("want 3 (capped), got %d", len(records))
}
}
func TestLoadLog(t *testing.T) {
h := New(t.TempDir())
rec := makeRecord("loadtest", "web01", "check+diff", "drift", time.Now())
want := []byte("PLAY RECAP\nweb01 : ok=4 changed=2 unreachable=0 failed=0\n")
if err := h.Save(rec, want); err != nil {
t.Fatal(err)
}
got, err := h.LoadLog(rec)
if err != nil {
t.Fatal(err)
}
if string(got) != string(want) {
t.Errorf("log mismatch: got %q want %q", got, want)
}
}
func TestSaveWithFindingsPersistsFindings(t *testing.T) {
h := New(t.TempDir())
rec := makeRecord("findings", "fleet", "check+diff", "drift", time.Now())
findings := []ansible.Finding{{
Host: "web01",
Task: "Deploy config",
Status: "changed",
Path: "/etc/example.conf",
Summary: "would change",
}}
if err := h.SaveWithFindings(rec, []byte("{}\n"), findings); err != nil {
t.Fatal(err)
}
if rec.FindingsFile == "" {
t.Fatal("FindingsFile not set")
}
got, err := h.LoadFindings(rec)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Host != "web01" || got[0].Path != "/etc/example.conf" {
t.Fatalf("findings = %+v", got)
}
}
func TestSanitize(t *testing.T) {
cases := []struct{ in, want string }{
{"fleet", "fleet"},
{"all", "all"},
{"startup compliance check", "startup_compliance_check"},
{"node-1", "node-1"},
{"my/host.example.com", "my_host_example_com"},
}
for _, tc := range cases {
got := sanitize(tc.in)
if got != tc.want {
t.Errorf("sanitize(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestStatusSummaryTable(t *testing.T) {
cases := []struct {
rec *RunRecord
want string
}{
// drift in check mode with a count
{&RunRecord{Mode: "check+diff", Status: "drift", Changed: 2}, "would change=2"},
// drift in check mode without count
{&RunRecord{Mode: "check+diff", Status: "drift", Changed: 0}, "would change"},
// drift in apply mode with count
{&RunRecord{Mode: "apply", Status: "drift", Changed: 3}, "changed=3"},
// drift in apply mode without count
{&RunRecord{Mode: "apply", Status: "drift", Changed: 0}, "changed"},
// clean
{&RunRecord{Mode: "check+diff", Status: "clean"}, "clean"},
// ok
{&RunRecord{Mode: "apply", Status: "ok"}, "ok"},
// failed with count
{&RunRecord{Mode: "check+diff", Status: "failed", Failed: 1}, "failed=1"},
// failed without count
{&RunRecord{Mode: "check+diff", Status: "failed", Failed: 0}, "failed"},
// unreachable
{&RunRecord{Mode: "check+diff", Status: "unreachable"}, "unreachable"},
}
for _, tc := range cases {
got := tc.rec.StatusSummary()
if got != tc.want {
t.Errorf("StatusSummary(%+v) = %q, want %q", tc.rec, got, tc.want)
}
}
}
func TestTimeLabel(t *testing.T) {
rec := &RunRecord{StartTime: time.Date(2026, 5, 23, 14, 32, 0, 0, time.UTC)}
if got := rec.TimeLabel(); got != "14:32" {
t.Errorf("TimeLabel = %q, want '14:32'", got)
}
}
func TestLogFileNameContainsHostAndMode(t *testing.T) {
h := New(t.TempDir())
rec := makeRecord("ts123", "adguard-1", "check+diff", "drift", time.Now())
h.Save(rec, []byte("log"))
if !strings.Contains(rec.LogFile, "adguard-1") {
t.Errorf("log file name %q does not contain host", rec.LogFile)
}
if !strings.Contains(rec.LogFile, "check+diff") {
t.Errorf("log file name %q does not contain mode", rec.LogFile)
}
}
+254
View File
@@ -0,0 +1,254 @@
package inventory
import (
"os"
"path/filepath"
"testing"
)
// writeInventory creates an inventory YAML file in dir and returns its path.
func writeInventory(t *testing.T, dir, yaml string) string {
t.Helper()
path := filepath.Join(dir, "inventory.yml")
if err := os.WriteFile(path, []byte(yaml), 0o644); err != nil {
t.Fatal(err)
}
return path
}
func TestLoadMissingFileReturnsEmpty(t *testing.T) {
path := filepath.Join(t.TempDir(), "nonexistent.yml")
inv, err := Load(path)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(inv.Hosts) != 0 {
t.Fatalf("want empty inventory, got %d hosts", len(inv.Hosts))
}
}
func TestLoadGroupedHosts(t *testing.T) {
yaml := `
all:
hosts:
web01:
ansible_host: 10.0.0.1
db01:
ansible_host: 10.0.0.2
children:
web:
hosts:
web01:
db:
hosts:
db01:
`
path := writeInventory(t, t.TempDir(), yaml)
inv, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(inv.Hosts) != 2 {
t.Fatalf("want 2 hosts, got %d", len(inv.Hosts))
}
byName := map[string]*Host{}
for _, h := range inv.Hosts {
byName[h.Name] = h
}
if byName["web01"].Group != "web" {
t.Errorf("web01 group = %q, want 'web'", byName["web01"].Group)
}
if byName["db01"].Group != "db" {
t.Errorf("db01 group = %q, want 'db'", byName["db01"].Group)
}
if byName["web01"].AnsibleHost != "10.0.0.1" {
t.Errorf("web01 ansible_host = %q", byName["web01"].AnsibleHost)
}
}
func TestLoadUngroupedHosts(t *testing.T) {
yaml := `
all:
hosts:
standalone:
ansible_host: 192.168.1.1
ansible_user: frank
ansible_port: 2222
`
path := writeInventory(t, t.TempDir(), yaml)
inv, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(inv.Hosts) != 1 {
t.Fatalf("want 1 host, got %d", len(inv.Hosts))
}
h := inv.Hosts[0]
if h.Name != "standalone" || h.Group != "" {
t.Errorf("host: %+v", h)
}
if h.AnsibleUser != "frank" || h.AnsiblePort != 2222 {
t.Errorf("vars: %+v", h)
}
}
func TestLoadInitialDriftStateIsUnknown(t *testing.T) {
yaml := `
all:
hosts:
web01: {}
`
path := writeInventory(t, t.TempDir(), yaml)
inv, err := Load(path)
if err != nil {
t.Fatal(err)
}
if inv.Hosts[0].DriftState != "unknown" {
t.Errorf("initial DriftState = %q, want 'unknown'", inv.Hosts[0].DriftState)
}
}
func TestAddPersistsAndSorts(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
inv, _ := Load(path) // empty, path doesn't exist yet
if err := inv.Add(&Host{Name: "zz", AnsibleHost: "10.0.0.3"}); err != nil {
t.Fatal(err)
}
if err := inv.Add(&Host{Name: "aa", AnsibleHost: "10.0.0.1"}); err != nil {
t.Fatal(err)
}
// Reload from disk to verify persistence.
inv2, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(inv2.Hosts) != 2 {
t.Fatalf("want 2 hosts after reload, got %d", len(inv2.Hosts))
}
// Hosts should be sorted alphabetically.
if inv2.Hosts[0].Name != "aa" || inv2.Hosts[1].Name != "zz" {
t.Errorf("sort order wrong: %v %v", inv2.Hosts[0].Name, inv2.Hosts[1].Name)
}
}
func TestAddDuplicateErrors(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
inv, _ := Load(path)
if err := inv.Add(&Host{Name: "web01"}); err != nil {
t.Fatal(err)
}
if err := inv.Add(&Host{Name: "web01"}); err == nil {
t.Fatal("expected error adding duplicate host")
}
}
func TestUpdatePreservesRuntimeState(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
inv, _ := Load(path)
if err := inv.Add(&Host{Name: "web01", AnsibleHost: "10.0.0.1"}); err != nil {
t.Fatal(err)
}
// Simulate runtime state set by a compliance scan.
inv.Hosts[0].DriftState = "drift"
inv.Hosts[0].LastCheck = "14:32"
// Update changes the IP but should keep runtime state.
updated := &Host{Name: "web01", AnsibleHost: "10.0.0.99"}
if err := inv.Update(updated); err != nil {
t.Fatal(err)
}
h := inv.Hosts[0]
if h.AnsibleHost != "10.0.0.99" {
t.Errorf("AnsibleHost not updated: %q", h.AnsibleHost)
}
if h.DriftState != "drift" || h.LastCheck != "14:32" {
t.Errorf("runtime state lost: DriftState=%q LastCheck=%q", h.DriftState, h.LastCheck)
}
}
func TestRemovePersists(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
inv, _ := Load(path)
inv.Add(&Host{Name: "web01"})
inv.Add(&Host{Name: "web02"})
if err := inv.Remove("web01"); err != nil {
t.Fatal(err)
}
inv2, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(inv2.Hosts) != 1 || inv2.Hosts[0].Name != "web02" {
t.Fatalf("after remove: %+v", inv2.Hosts)
}
}
func TestRemoveMissingErrors(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
inv, _ := Load(path)
if err := inv.Remove("nonexistent"); err == nil {
t.Fatal("expected error removing non-existent host")
}
}
func TestGroupsReturnsSortedUnique(t *testing.T) {
yaml := `
all:
hosts:
a: {}
b: {}
c: {}
children:
kube:
hosts:
a:
b:
dns:
hosts:
c:
`
path := writeInventory(t, t.TempDir(), yaml)
inv, err := Load(path)
if err != nil {
t.Fatal(err)
}
groups := inv.Groups()
if len(groups) != 2 || groups[0] != "dns" || groups[1] != "kube" {
t.Errorf("groups = %v, want [dns kube]", groups)
}
}
func TestRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "inventory.yml")
inv, _ := Load(path)
for _, name := range []string{"node-1", "node-2", "node-3"} {
inv.Add(&Host{Name: name, Group: "kube", AnsibleHost: "10.0.0.1"})
}
inv.Remove("node-2")
inv2, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(inv2.Hosts) != 2 {
t.Fatalf("want 2 hosts, got %d: %+v", len(inv2.Hosts), inv2.Hosts)
}
names := map[string]bool{}
for _, h := range inv2.Hosts {
names[h.Name] = true
}
if !names["node-1"] || !names["node-3"] || names["node-2"] {
t.Errorf("unexpected hosts after round-trip: %v", names)
}
}
+49 -9
View File
@@ -17,8 +17,21 @@ var excludedPrefixes = []string{
"host_vars",
}
// Discover scans dir for Ansible playbook files, excluding support files.
// Returns base filenames sorted alphabetically.
// excludedDirs are Ansible support directories skipped when scanning one level deep.
var excludedDirs = map[string]bool{
"roles": true,
"group_vars": true,
"host_vars": true,
"templates": true,
"files": true,
"library": true,
"filter_plugins": true,
"inventory": true,
".git": true,
}
// Discover scans dir for Ansible playbook files at the top level and one subdirectory deep.
// Returns paths relative to dir (e.g. "site.yml", "services/pi-hole.yml"), sorted.
func Discover(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
@@ -31,22 +44,49 @@ func Discover(dir string) ([]string, error) {
var out []string
for _, e := range entries {
if e.IsDir() {
name := e.Name()
if excludedDirs[strings.ToLower(name)] {
continue
}
sub, err := discoverInDir(filepath.Join(dir, name), name)
if err != nil {
return nil, err
}
out = append(out, sub...)
continue
}
name := e.Name()
ext := strings.ToLower(filepath.Ext(name))
if ext != ".yml" && ext != ".yaml" {
continue
if isPlaybookFile(name) && !excluded(name) {
out = append(out, name)
}
if excluded(name) {
continue
}
out = append(out, name)
}
sort.Strings(out)
return out, nil
}
func discoverInDir(absDir, relPrefix string) ([]string, error) {
entries, err := os.ReadDir(absDir)
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if isPlaybookFile(name) && !excluded(name) {
out = append(out, filepath.Join(relPrefix, name))
}
}
return out, nil
}
func isPlaybookFile(name string) bool {
ext := strings.ToLower(filepath.Ext(name))
return ext == ".yml" || ext == ".yaml"
}
func excluded(name string) bool {
lower := strings.ToLower(name)
if excludedNames[lower] {
+98
View File
@@ -0,0 +1,98 @@
package playbooks
import (
"os"
"path/filepath"
"testing"
)
func TestDiscover_topLevelAndNested(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "site.yml"), "---\n")
mustWrite(t, filepath.Join(dir, "requirements.yml"), "---\n")
mustMkdir(t, filepath.Join(dir, "services"))
mustWrite(t, filepath.Join(dir, "services", "pi-hole.yml"), "---\n")
mustMkdir(t, filepath.Join(dir, "roles", "foo", "tasks"))
mustWrite(t, filepath.Join(dir, "roles", "foo", "tasks", "main.yml"), "---\n")
got, err := Discover(dir)
if err != nil {
t.Fatal(err)
}
want := []string{"services/pi-hole.yml", "site.yml"}
if len(got) != len(want) {
t.Fatalf("got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got %v want %v", got, want)
}
}
}
func TestDiscover_excludesGroupVarsDir(t *testing.T) {
dir := t.TempDir()
mustMkdir(t, filepath.Join(dir, "group_vars"))
mustWrite(t, filepath.Join(dir, "group_vars", "all.yml"), "---\n")
mustWrite(t, filepath.Join(dir, "site.yml"), "---\n")
got, err := Discover(dir)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0] != "site.yml" {
t.Fatalf("got %v", got)
}
}
func TestDiscover_homelabPlaybooksLayout(t *testing.T) {
home, err := os.UserHomeDir()
if err != nil {
t.Skip(err)
}
dir := filepath.Join(home, ".ansibletui", "playbooks")
if _, err := os.Stat(dir); err != nil {
t.Skip("homelab playbooks dir not present")
}
got, err := Discover(dir)
if err != nil {
t.Fatal(err)
}
want := map[string]bool{
"site.yml": true,
"setup.yml": true,
"passwordless.yml": true,
"compliance.yaml": true,
"services/pi-hole.yml": true,
"services/kubernetes.yml": true,
"security/selinux-permissive.yml": true,
"security/ipv6-disable.yml": true,
"playbooks/xcp-guest-tools.yml": true,
}
for _, p := range got {
if !want[p] {
t.Errorf("unexpected playbook in list: %q", p)
}
delete(want, p)
}
if len(want) > 0 {
t.Errorf("missing playbooks: %v", want)
}
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
func mustMkdir(t *testing.T, path string) {
t.Helper()
if err := os.MkdirAll(path, 0o755); err != nil {
t.Fatal(err)
}
}
+40 -9
View File
@@ -4,19 +4,43 @@ import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"sync"
"ansibletui/internal/ansible"
)
// Run executes argv, streams each output line to lineCh (closed on return),
// and returns the exit code, combined stdout+stderr log, and any exec error.
func Run(ctx context.Context, argv []string, lineCh chan<- string) (exitCode int, log []byte, err error) {
// workDir is the playbook repository root (for ansible.cfg and roles/). Pass "" to use the process cwd.
func Run(ctx context.Context, argv []string, workDir string, lineCh chan<- string) (exitCode int, log []byte, err error) {
return RunWithEnv(ctx, argv, workDir, nil, lineCh)
}
// RunWithEnv executes argv with extra environment variables.
func RunWithEnv(ctx context.Context, argv []string, workDir string, extraEnv map[string]string, lineCh chan<- string) (exitCode int, log []byte, err error) {
defer close(lineCh)
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
env := os.Environ()
if workDir != "" {
cmd.Dir = workDir
cfg := filepath.Join(workDir, "ansible.cfg")
if _, statErr := os.Stat(cfg); statErr == nil {
env = append(env, "ANSIBLE_CONFIG="+cfg)
}
rolesDir := filepath.Join(workDir, "roles")
if _, statErr := os.Stat(rolesDir); statErr == nil {
env = append(env, "ANSIBLE_ROLES_PATH="+rolesDir)
}
}
for k, v := range extraEnv {
env = append(env, k+"="+v)
}
cmd.Env = env
pr, pw := io.Pipe()
var buf bytes.Buffer
@@ -53,10 +77,17 @@ func Run(ctx context.Context, argv []string, lineCh chan<- string) (exitCode int
return code, buf.Bytes(), runErr
}
// Ping checks whether a host is reachable via the ansible ping module.
func Ping(ctx context.Context, inventoryPath, host string) (bool, error) {
argv := ansible.BuildPingArgs(inventoryPath, host)
cmd := exec.CommandContext(ctx, argv[0], argv[1:]...)
err := cmd.Run()
return err == nil, err
// Probe checks TCP reachability on the given address and port.
// It returns whether the connection succeeded and any error.
func Probe(ctx context.Context, addr string, port int) (bool, error) {
if port <= 0 {
port = 22
}
d := net.Dialer{}
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", addr, port))
if err != nil {
return false, err
}
conn.Close()
return true, nil
}
+98
View File
@@ -0,0 +1,98 @@
package runner
import (
"context"
"strings"
"testing"
"time"
)
func drain(ch <-chan string) []string {
var lines []string
for l := range ch {
lines = append(lines, l)
}
return lines
}
func TestRunEcho(t *testing.T) {
ch := make(chan string, 16)
code, log, err := Run(context.Background(), []string{"echo", "hello"}, "", ch)
<-ch // channel is closed by Run; drain any remaining
if err != nil {
// On non-zero exit, err is an *exec.ExitError — only fatal if code != 0.
t.Logf("Run error (may be normal): %v", err)
}
if code != 0 {
t.Fatalf("exit code = %d, want 0", code)
}
if !strings.Contains(string(log), "hello") {
t.Fatalf("log does not contain 'hello': %q", string(log))
}
}
func TestRunFalse(t *testing.T) {
ch := make(chan string, 16)
code, _, _ := Run(context.Background(), []string{"false"}, "", ch)
for range ch {
}
if code == 0 {
t.Fatal("expected non-zero exit code from 'false'")
}
}
func TestRunStreamsLines(t *testing.T) {
ch := make(chan string, 16)
var received []string
done := make(chan struct{})
go func() {
received = drain(ch)
close(done)
}()
Run(context.Background(), []string{"printf", "line1\nline2\nline3\n"}, "", ch)
<-done
if len(received) != 3 {
t.Fatalf("want 3 lines, got %d: %v", len(received), received)
}
if received[0] != "line1" || received[1] != "line2" || received[2] != "line3" {
t.Fatalf("unexpected lines: %v", received)
}
}
func TestRunContextCancel(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
ch := make(chan string, 16)
done := make(chan struct{})
go func() {
// 'sleep 30' will be killed when ctx is cancelled.
Run(ctx, []string{"sleep", "30"}, "", ch)
for range ch {
}
close(done)
}()
// Give sleep a moment to start, then cancel.
time.Sleep(100 * time.Millisecond)
cancel()
select {
case <-done:
// Good — Run returned promptly after cancel.
case <-time.After(3 * time.Second):
t.Fatal("Run did not return within 3s after context cancel")
}
}
func TestRunMissingBinary(t *testing.T) {
ch := make(chan string, 16)
_, _, err := Run(context.Background(), []string{"this-binary-does-not-exist-anywhere"}, "", ch)
for range ch {
}
if err == nil {
t.Fatal("expected error for missing binary, got nil")
}
}
+2 -2
View File
@@ -66,12 +66,12 @@ func (a *App) updateAddServer(msg tea.Msg) (tea.Model, tea.Cmd) {
a.editingHost = nil
return a, nil
case key.Matches(km, keys.Tab), key.Matches(km, keys.Down):
case key.Matches(km, keys.Tab), km.String() == "down":
a.formFocus = (a.formFocus + 1) % 5
a.refocusForm()
return a, nil
case key.Matches(km, keys.ShiftTab), key.Matches(km, keys.Up):
case key.Matches(km, keys.ShiftTab), km.String() == "up":
a.formFocus = (a.formFocus + 4) % 5
a.refocusForm()
return a, nil
+48
View File
@@ -0,0 +1,48 @@
package ui
import (
"testing"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
)
func TestAddServerFormTreatsJKAsText(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.openAddServerScreen(nil)
for _, r := range []rune{'j', 'k'} {
model, cmd := app.updateAddServer(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
if cmd != nil {
cmd()
}
app = model.(*App)
}
if app.formFocus != 0 {
t.Fatalf("focus moved to %d, want 0", app.formFocus)
}
if got := app.formFields[0].Value(); got != "jk" {
t.Fatalf("hostname input = %q, want %q", got, "jk")
}
}
func TestAddServerFormArrowKeysStillMoveFocus(t *testing.T) {
app := New(&config.Config{}, &inventory.Inventory{}, history.New(t.TempDir()))
app.openAddServerScreen(nil)
model, _ := app.updateAddServer(tea.KeyMsg{Type: tea.KeyDown})
app = model.(*App)
if app.formFocus != 1 {
t.Fatalf("down focus = %d, want 1", app.formFocus)
}
model, _ = app.updateAddServer(tea.KeyMsg{Type: tea.KeyUp})
app = model.(*App)
if app.formFocus != 0 {
t.Fatalf("up focus = %d, want 0", app.formFocus)
}
}
+636 -82
View File
@@ -1,16 +1,21 @@
package ui
import (
"bytes"
"context"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
@@ -25,6 +30,7 @@ const (
ScreenAddServer
ScreenCmdFlow
ScreenRunDetails
ScreenHostDetails
)
const (
@@ -39,6 +45,9 @@ const (
TabConfig = 2
)
// maxRecentRuns caps the runs loaded into memory (used by both home panel and jobs tab).
const maxRecentRuns = 100
// Command-flow steps
const (
StepPlaybook = 0
@@ -48,6 +57,15 @@ const (
StepDone = 4
)
// complianceJobResult records the outcome of one job in a compliance scan.
type complianceJobResult struct {
label string // e.g. "all", "dns", "appliance"
playbook string
status string // clean, drift, failed, unreachable
changed int
failed int
}
// App is the root Bubble Tea model.
type App struct {
// Infrastructure
@@ -71,6 +89,7 @@ type App struct {
filtering bool
filterStr string
runs []*history.RunRecord
hostFindings map[string][]ansible.Finding
// --- Add/Edit server form ---
// formFields order: name, group, ansible_host, ansible_user, ansible_port
@@ -80,28 +99,53 @@ type App struct {
formErr string
// --- Command flow ---
flowHost string
flowPlaybook string
flowMode ansible.Mode
flowStep int
flowArgv []string
playbookList []string
pbCursor int
modeCursor int
outputLines []string
outputVp viewport.Model
flowRecord *history.RunRecord
flowLog []byte
runCh <-chan tea.Msg
cancelRun context.CancelFunc
flowHost string
flowPlaybook string
flowMode ansible.Mode
flowStep int
flowArgv []string
playbookList []string
pbCursor int
modeCursor int
outputLines []string
outputVp viewport.Model
flowRecord *history.RunRecord
flowLog []byte
runCh <-chan tea.Msg
cancelRun context.CancelFunc
runStarted time.Time
showRunLogs bool
// --- Compliance scan ---
complianceMode bool
complianceRunning bool
complianceAction string
complianceSource string
complianceJobs []compliance.Job
complianceJob compliance.Job
complianceIndex int
complianceTotal int
complianceSummary compliance.Summary
complianceJobResults []complianceJobResult // per-job outcomes from last scan
// --- Run details ---
viewingRun *history.RunRecord
logVp viewport.Model
viewingRun *history.RunRecord
viewingFindings []ansible.Finding
viewingHost string
logVp viewport.Model
// Status / error messages
statusMsg string
errMsg string
// Config tab / git
configRepoFocus int // 0 playbooks, 1 inventory
configFieldFocus int // field index within repo (remote, branch, …)
configEditing bool
configEditInput textinput.Model
configGitPlaybooks string
configGitInventory string
configSyncing bool
}
// New constructs the initial App model.
@@ -118,19 +162,32 @@ func New(cfg *config.Config, inv *inventory.Inventory, hist *history.History) *A
fi.Placeholder = "filter..."
fi.Prompt = "/ "
return &App{
cfg: cfg,
inv: inv,
hist: hist,
formFields: fields,
filterInput: fi,
cfgEdit := textinput.New()
cfgEdit.Placeholder = "git@github.com:you/repo.git"
cfgEdit.CharLimit = 512
cfgEdit.Width = 60
app := &App{
cfg: cfg,
inv: inv,
hist: hist,
hostFindings: map[string][]ansible.Finding{},
formFields: fields,
filterInput: fi,
configEditInput: cfgEdit,
}
app.loadComplianceMapping()
return app
}
// ---- tea.Model interface ----
func (a *App) Init() tea.Cmd {
return loadRunsCmd(a.hist)
cmds := []tea.Cmd{loadRunsCmd(a.hist)}
if cmd := a.startStartupComplianceCheck(); cmd != nil {
cmds = append(cmds, cmd)
}
return tea.Batch(cmds...)
}
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@@ -149,6 +206,13 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case runsLoadedMsg:
a.runs = msg.runs
if a.runCursor >= len(a.runs) {
if len(a.runs) > 0 {
a.runCursor = len(a.runs) - 1
} else {
a.runCursor = 0
}
}
return a, nil
case pingResultMsg:
@@ -156,24 +220,17 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if h.Name == msg.host {
reach := msg.reachable
h.Reachable = &reach
if !msg.reachable && msg.err != nil {
h.LastError = msg.err.Error()
}
break
}
}
if msg.reachable {
a.statusMsg = fmt.Sprintf("%s is reachable", msg.host)
a.statusMsg = fmt.Sprintf("%s TCP port is reachable", msg.host)
a.errMsg = ""
} else {
errStr := ""
if msg.err != nil {
errStr = msg.err.Error()
}
if strings.Contains(errStr, "executable file not found") ||
strings.Contains(errStr, "not found in $PATH") ||
strings.Contains(errStr, "cannot find the file") {
a.errMsg = "ansible not found in PATH — run from WSL or install Ansible"
} else {
a.errMsg = fmt.Sprintf("ping %s failed: %v", msg.host, msg.err)
}
a.errMsg = fmt.Sprintf("ping %s: %v", msg.host, msg.err)
a.statusMsg = ""
}
return a, nil
@@ -184,30 +241,102 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
a.outputVp.GotoBottom()
return a, waitRun(a.runCh)
case complianceJobStartedMsg:
a.complianceJob = msg.job
a.complianceIndex = msg.index
a.complianceTotal = msg.total
verb := "checking"
activeState := "scanning"
if a.flowMode == ansible.ModeApply {
verb = "applying"
activeState = "fixing"
}
for _, h := range hostsForJob(a.inv, msg.job) {
h.DriftState = activeState
}
a.statusMsg = fmt.Sprintf("%s %s (%s) — job %d/%d", verb, msg.job.Playbook, msg.job.Label(), msg.index+1, msg.total)
a.errMsg = ""
return a, waitRun(a.runCh)
case complianceJobDoneMsg:
a.complianceJobResults = append(a.complianceJobResults, complianceJobResult{
label: msg.job.Label(),
playbook: msg.job.Playbook,
status: msg.status,
changed: msg.changed,
failed: msg.failed,
})
return a, waitRun(a.runCh)
case tea.MouseMsg:
return a.handleMouse(msg)
case gitOpDoneMsg:
a.configSyncing = false
if msg.err != nil {
a.errMsg = fmt.Sprintf("%s %s: %v", msg.repo, msg.op, msg.err)
a.statusMsg = ""
} else {
a.statusMsg = fmt.Sprintf("%s %s: %s", msg.repo, msg.op, msg.summary)
a.errMsg = ""
if msg.op == "sync" {
switch msg.repo {
case "playbooks":
if a.cfg.PlaybooksGit != nil {
a.cfg.PlaybooksGit.Enabled = true
_ = a.cfg.Save()
}
case "inventory":
if a.cfg.InventoryGit != nil {
a.cfg.InventoryGit.Enabled = true
_ = a.cfg.Save()
}
if inv, err := inventory.Load(a.cfg.EffectiveInventoryPath()); err == nil {
a.inv = inv
}
}
}
}
return a, refreshGitStatusCmd(a.cfg)
case gitStatusMsg:
a.configGitPlaybooks = msg.playbooks
a.configGitInventory = msg.inventory
return a, nil
case runDoneMsg:
a.flowRecord = msg.record
a.flowLog = msg.log
a.applyFindings(msg.findings, msg.record)
a.flowStep = StepDone
if msg.err != nil {
a.errMsg = fmt.Sprintf("run error: %v", msg.err)
}
if msg.record != nil {
for _, h := range a.inv.Hosts {
if h.Name == a.flowHost || a.flowHost == "all" {
switch msg.record.Status {
case "clean", "drift":
h.DriftState = msg.record.Status
h.LastCheck = time.Now().Format("15:04")
case "ok":
h.DriftState = "clean"
h.LastApply = time.Now().Format("15:04")
}
}
return a, loadRunsCmd(a.hist)
case complianceDoneMsg:
a.complianceRunning = false
// Any host still showing an active state didn't produce a recap
// (e.g. unreachable) — reset so it doesn't stay stuck.
for _, h := range a.inv.Hosts {
if h.DriftState == "scanning" || h.DriftState == "fixing" {
h.DriftState = "unknown"
}
}
a.flowRecord = msg.record
a.flowLog = msg.log
a.applyFindings(msg.findings, msg.record)
a.complianceSummary = msg.summary
if a.complianceMode {
a.flowStep = StepDone
}
if msg.err != nil {
a.errMsg = fmt.Sprintf("%s: %v", msg.action, msg.err)
a.statusMsg = ""
} else if msg.record != nil {
a.statusMsg = msg.action + " complete"
a.errMsg = ""
}
return a, loadRunsCmd(a.hist)
}
@@ -220,6 +349,8 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a.updateCmdFlow(msg)
case ScreenRunDetails:
return a.updateRunDetails(msg)
case ScreenHostDetails:
return a.updateHostDetails(msg)
}
return a, nil
}
@@ -237,6 +368,8 @@ func (a *App) View() string {
return a.viewCmdFlow()
case ScreenRunDetails:
return a.viewRunDetails()
case ScreenHostDetails:
return a.viewHostDetails()
}
return ""
}
@@ -245,17 +378,17 @@ func (a *App) View() string {
func loadRunsCmd(hist *history.History) tea.Cmd {
return func() tea.Msg {
runs, _ := hist.List(20)
runs, _ := hist.List(maxRecentRuns)
return runsLoadedMsg{runs: runs}
}
}
func pingCmd(inventoryPath, host string) tea.Cmd {
func pingCmd(name, addr string, port int) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
ok, err := runner.Ping(ctx, inventoryPath, host)
return pingResultMsg{host: host, reachable: ok, err: err}
ok, err := runner.Probe(ctx, addr, port)
return pingResultMsg{host: name, reachable: ok, err: err}
}
}
@@ -272,12 +405,21 @@ func waitRun(ch <-chan tea.Msg) tea.Cmd {
}
}
func ansibleJSONLEnv() map[string]string {
return map[string]string{
"ANSIBLE_STDOUT_CALLBACK": "ansible.posix.jsonl",
"ANSIBLE_JSON_INDENT": "0",
}
}
// startRun launches a playbook run goroutine and returns the first waitRun Cmd.
func (a *App) startRun(argv []string, playbook, host string, mode ansible.Mode) tea.Cmd {
ch := make(chan tea.Msg, 256)
ctx, cancel := context.WithCancel(context.Background())
a.cancelRun = cancel
a.runCh = ch
a.runStarted = time.Now()
a.showRunLogs = false
hist := a.hist
inv := a.inv
@@ -296,12 +438,18 @@ func (a *App) startRun(argv []string, playbook, host string, mode ansible.Mode)
resCh := make(chan res, 1)
go func() {
code, log, err := runner.Run(ctx, argv, lineCh)
code, log, err := runner.RunWithEnv(ctx, argv, a.cfg.EffectivePlaybookDir(), ansibleJSONLEnv(), lineCh)
resCh <- res{code, log, err}
}()
for line := range lineCh {
ch <- outputLineMsg{line: line}
if compact, ok := ansible.CompactJSONLLine(line, playbook, nil); ok {
for _, compactLine := range strings.Split(compact, "\n") {
ch <- outputLineMsg{line: compactLine}
}
} else if strings.TrimSpace(line) != "" {
ch <- outputLineMsg{line: line}
}
}
r := <-resCh
@@ -316,38 +464,348 @@ func (a *App) startRun(argv []string, playbook, host string, mode ansible.Mode)
ExitCode: r.code,
}
recaps := ansible.ParseRecap(string(r.log))
parsed := ansible.ParseJSONL(r.log, playbook, nil)
recaps := parsed.Recaps
findings := parsed.Findings
if len(recaps) == 0 {
recaps = ansible.ParseRecap(string(r.log))
}
if len(recaps) > 0 {
rc := recaps[0]
rec.OK = rc.OK
rec.Changed = rc.Changed
rec.Failed = rc.Failed
rec.Unreachable = rc.Unreachable
rec.Skipped = rc.Skipped
rec.Status = rc.DriftState()
for _, h := range inv.Hosts {
if h.Name == host || host == "all" {
h.DriftState = rc.DriftState()
}
}
summary := compliance.SummarizeRecaps(recaps, mode)
rec.OK = summary.OK
rec.Changed = summary.Changed
rec.Failed = summary.Failed
rec.Unreachable = summary.Unreachable
rec.Skipped = summary.Skipped
rec.Status = summary.Status
rec.DriftCount = countStatus(findings, "changed")
compliance.ApplyRecaps(inv, recaps, mode, end)
} else if r.err != nil {
rec.Status = "failed"
} else {
rec.Status = "ok"
}
_ = hist.Save(rec, r.log)
ch <- runDoneMsg{record: rec, log: r.log, err: r.err}
if rec.DriftCount == 0 {
rec.DriftCount = countStatus(findings, "changed")
}
_ = hist.SaveWithFindings(rec, r.log, findings)
ch <- runDoneMsg{record: rec, log: r.log, findings: findings, err: r.err}
}()
return waitRun(ch)
}
func (a *App) startComplianceRun(jobs []compliance.Job, mode ansible.Mode, showScreen bool, action string) tea.Cmd {
ch := make(chan tea.Msg, 512)
ctx, cancel := context.WithCancel(context.Background())
a.cancelRun = cancel
a.runCh = ch
a.runStarted = time.Now()
a.showRunLogs = false
a.outputLines = nil
a.outputVp.SetContent("")
a.complianceMode = showScreen
a.complianceRunning = true
a.complianceAction = action
a.complianceJobs = jobs
a.complianceIndex = 0
a.complianceTotal = len(jobs)
a.complianceSummary = compliance.Summary{}
a.complianceJobResults = nil // reset per-job breakdown for this new scan
a.flowMode = mode
if showScreen {
a.screen = ScreenCmdFlow
a.flowHost = "fleet"
a.flowPlaybook = action
a.flowStep = StepExecuting
}
hist := a.hist
inv := a.inv
cfg := a.cfg
go func() {
defer close(ch)
start := time.Now()
// For apply runs, expand each group job into per-host jobs so changes
// roll out one server at a time per group rather than hitting all
// members simultaneously. Check/check+diff runs keep group-level limits.
if mode == ansible.ModeApply {
jobs = expandGroupJobsPerHost(jobs, inv)
}
// Group jobs by Group field. Jobs in the same group run serially (one
// host at a time); groups themselves run concurrently.
type groupBatch []compliance.Job
batchMap := map[string]groupBatch{}
var batchOrder []string
for _, job := range jobs {
g := job.Group
if _, ok := batchMap[g]; !ok {
batchOrder = append(batchOrder, g)
}
batchMap[g] = append(batchMap[g], job)
}
var mu sync.Mutex
var combined bytes.Buffer
var allRecaps []ansible.Recap
var allFindings []ansible.Finding
var firstErr error
exitCode := 0
startedIdx := 0
var wg sync.WaitGroup
for _, g := range batchOrder {
batch := batchMap[g]
wg.Add(1)
go func(batch groupBatch) {
defer wg.Done()
for _, job := range batch {
select {
case <-ctx.Done():
return
default:
}
mu.Lock()
idx := startedIdx
startedIdx++
mu.Unlock()
ch <- complianceJobStartedMsg{job: job, index: idx, total: len(jobs)}
prefix := "[" + job.Label() + "] "
lineCh := make(chan string, 256)
argv := ansible.BuildPlaybookArgsWithTags(cfg.EffectiveInventoryPath(), job.Playbook, job.Limit, job.Tags, mode)
type res struct {
code int
log []byte
err error
}
resCh := make(chan res, 1)
jobStart := time.Now()
go func() {
code, log, err := runner.RunWithEnv(ctx, argv, cfg.EffectivePlaybookDir(), ansibleJSONLEnv(), lineCh)
resCh <- res{code, log, err}
}()
for line := range lineCh {
if compact, ok := ansible.CompactJSONLLine(line, job.Playbook, job.Tags); ok {
for _, compactLine := range strings.Split(compact, "\n") {
ch <- outputLineMsg{line: prefix + compactLine}
}
} else if strings.TrimSpace(line) != "" {
ch <- outputLineMsg{line: prefix + line}
}
}
r := <-resCh
jobEnd := time.Now()
parsed := ansible.ParseJSONL(r.log, job.Playbook, job.Tags)
recaps := parsed.Recaps
if len(recaps) == 0 {
recaps = ansible.ParseRecap(string(r.log))
}
findings := parsed.Findings
jobSummary := compliance.SummarizeRecaps(recaps, mode)
if len(recaps) == 0 {
if r.err != nil {
jobSummary.Status = "failed"
} else {
jobSummary.Status = "clean"
}
}
jobRec := &history.RunRecord{
Playbook: job.Playbook,
Host: job.Label(),
Mode: mode.String(),
Status: jobSummary.Status,
OK: jobSummary.OK,
Changed: jobSummary.Changed,
Failed: jobSummary.Failed,
Unreachable: jobSummary.Unreachable,
Skipped: jobSummary.Skipped,
StartTime: jobStart,
EndTime: jobEnd,
ExitCode: r.code,
Tags: job.Tags,
DriftCount: countStatus(findings, "changed"),
}
_ = hist.SaveWithFindings(jobRec, r.log, findings)
ch <- complianceJobDoneMsg{
job: job,
index: idx,
total: len(jobs),
status: jobSummary.Status,
changed: jobSummary.Changed,
failed: jobSummary.Failed,
findings: findings,
}
mu.Lock()
if r.code != 0 && exitCode == 0 {
exitCode = r.code
}
if r.err != nil && firstErr == nil {
firstErr = r.err
}
combined.WriteString(fmt.Sprintf("=== %s [%s] ===\n", job.Playbook, job.Label()))
combined.Write(r.log)
if len(r.log) == 0 || r.log[len(r.log)-1] != '\n' {
combined.WriteByte('\n')
}
allRecaps = append(allRecaps, recaps...)
for _, f := range findings {
allFindings = append(allFindings, f)
}
mu.Unlock()
}
}(batch)
}
wg.Wait()
end := time.Now()
// Apply all recaps at once so per-host state is the worst-case across
// all playbooks rather than whichever job happened to finish last.
compliance.ApplyRecaps(inv, allRecaps, mode, end)
summary := compliance.SummarizeRecaps(allRecaps, mode)
if len(allRecaps) == 0 {
if firstErr != nil {
summary.Status = "failed"
} else {
summary.Status = "clean"
}
}
rec := &history.RunRecord{
Playbook: action,
Host: "fleet",
Mode: mode.String(),
Status: summary.Status,
OK: summary.OK,
Changed: summary.Changed,
Failed: summary.Failed,
Unreachable: summary.Unreachable,
Skipped: summary.Skipped,
StartTime: start,
EndTime: end,
ExitCode: exitCode,
DriftCount: countStatus(allFindings, "changed"),
}
_ = hist.SaveWithFindings(rec, combined.Bytes(), allFindings)
ch <- complianceDoneMsg{record: rec, log: combined.Bytes(), findings: allFindings, summary: summary, action: action, err: firstErr}
}()
return waitRun(ch)
}
func (a *App) startStartupComplianceCheck() tea.Cmd {
jobs := compliance.Plan(a.cfg.Compliance, a.inv)
if len(jobs) == 0 {
return nil
}
a.statusMsg = fmt.Sprintf("startup compliance check queued: %d mapped checks", len(jobs))
return a.startComplianceRun(jobs, ansible.ModeCheckDiff, false, "startup compliance check")
}
func (a *App) loadComplianceMapping() {
cfg, source, ok, err := compliance.LoadFromDir(a.cfg.EffectivePlaybookDir())
if err != nil {
a.errMsg = fmt.Sprintf("compliance.yaml: %v", err)
return
}
if ok {
a.cfg.Compliance = cfg
a.complianceSource = source
}
}
func (a *App) applyFindings(findings []ansible.Finding, rec *history.RunRecord) {
if len(findings) == 0 {
return
}
if a.hostFindings == nil {
a.hostFindings = map[string][]ansible.Finding{}
}
seenHosts := map[string]bool{}
for _, f := range findings {
if f.Host == "" {
continue
}
seenHosts[f.Host] = true
}
for host := range seenHosts {
a.hostFindings[host] = findingsForHost(findings, host)
}
for _, h := range a.inv.Hosts {
if !seenHosts[h.Name] {
continue
}
hostFindings := findingsForHost(findings, h.Name)
if diag := firstDiagnostic(hostFindings); diag != "" {
h.LastError = diag
} else if h.LastError != "" && rec != nil && rec.Status != "failed" && rec.Status != "unreachable" {
h.LastError = ""
}
}
}
func findingsForHost(findings []ansible.Finding, host string) []ansible.Finding {
var out []ansible.Finding
for _, f := range findings {
if f.Host == host {
out = append(out, f)
}
}
return out
}
func firstDiagnostic(findings []ansible.Finding) string {
for _, f := range findings {
if f.Diagnostic != "" {
return f.Diagnostic
}
}
for _, f := range findings {
if f.Status == "failed" || f.Status == "unreachable" {
return f.Summary
}
}
return ""
}
func countStatus(findings []ansible.Finding, status string) int {
n := 0
for _, f := range findings {
if f.Status == status {
n++
}
}
return n
}
func countHostDrift(findings []ansible.Finding) int {
return countStatus(findings, "changed")
}
// ---- Helpers ----
func (a *App) filteredServers() []*inventory.Host {
if a.filterStr == "" {
return a.inv.Hosts
out := append([]*inventory.Host(nil), a.inv.Hosts...)
sortHostsForDashboard(out)
return out
}
f := strings.ToLower(a.filterStr)
var out []*inventory.Host
@@ -357,9 +815,19 @@ func (a *App) filteredServers() []*inventory.Host {
out = append(out, h)
}
}
sortHostsForDashboard(out)
return out
}
func sortHostsForDashboard(hosts []*inventory.Host) {
sort.SliceStable(hosts, func(i, j int) bool {
if hosts[i].Group != hosts[j].Group {
return hosts[i].Group < hosts[j].Group
}
return hosts[i].Name < hosts[j].Name
})
}
func (a *App) selectedServer() *inventory.Host {
hosts := a.filteredServers()
if len(hosts) == 0 || a.serverCursor >= len(hosts) {
@@ -376,7 +844,7 @@ func (a *App) selectedRunRecord() *history.RunRecord {
}
func (a *App) loadPlaybooks() {
pbs, _ := playbooks.Discover(a.cfg.PlaybookDir)
pbs, _ := playbooks.Discover(a.cfg.EffectivePlaybookDir())
a.playbookList = pbs
a.pbCursor = 0
// Set cursor to recent playbook if available
@@ -413,24 +881,33 @@ func (a *App) handleMouse(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
// homeLayout returns approximate screen coordinates for the home layout.
// These are estimates; they are intentionally simple so row selection works
// at typical terminal sizes without precise measurement.
func (a *App) homeLayout() (serverRowY0, serverX0, serverX1, runX0 int) {
func (a *App) homeLayout() (serverRowY0, serverX0, serverX1, rightX0, runsRowY0 int) {
// title bar: y=0
// servers panel: top border(y=1), header(y=2), blank(y=3), colhdr(y=4), divider(y=5), data(y=6+)
const titleLines = 1
const topRowLines = 6 // fleet+actions panels rendered height (border+content)
const panelHeaderLines = 3 // header + MarginBottom + col-headers + divider
const panelPreamble = 5 // border + "Servers" + MarginBottom-blank + col-header + divider
serverRowY0 = titleLines + topRowLines + panelHeaderLines
serverRowY0 = titleLines + panelPreamble
sideW := 6
mainW := a.width - sideW - 1
serversW := mainW * 65 / 100
serversW := mainW * 64 / 100
serverX0 = sideW + 2 // sidebar + border + padding
serverX0 = sideW + 2 // sidebar + left border + left padding
serverX1 = sideW + serversW
runX0 = serverX1 + 2
rightX0 = serverX1 + 2 // spacer(1) + left border of right panel(1)
// Runs panel sits below the detail panel (detailH=12) in the right column.
// runs panel preamble: border(1) + "Recent Runs"(1) + MarginBottom-blank(1) = 3
const detailH = 12
const runsPreamble = 3
runsRowY0 = titleLines + detailH + runsPreamble
return
}
func (a *App) handleHomeMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
serverRowY0, serverX0, serverX1, runX0 := a.homeLayout()
serverRowY0, serverX0, serverX1, rightX0, runsRowY0 := a.homeLayout()
const sideW = 6
switch msg.Button {
case tea.MouseButtonWheelUp:
@@ -445,6 +922,28 @@ func (a *App) handleHomeMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
}
x, y := msg.X, msg.Y
// Sidebar tab clicks (SERV / JOBS / CFG).
// renderSidebar emits: empty, tab0, empty, tab1, empty, tab2, empty
// starting at body y=0 (abs y=1, since title bar is y=0).
if x < sideW {
bodyY := y - 1
switch {
case bodyY >= 1 && bodyY <= 2:
a.sidebarTab = TabServers
case bodyY >= 3 && bodyY <= 4:
a.sidebarTab = TabJobs
case bodyY >= 5 && bodyY <= 6:
if a.sidebarTab != TabConfig {
a.sidebarTab = TabConfig
a.syncActivePanelToTab()
return a, a.configTabEnterCmd()
}
a.sidebarTab = TabConfig
}
a.syncActivePanelToTab()
return a, nil
}
if x >= serverX0 && x < serverX1 {
// Clicked in the servers panel
a.activePanel = PanelServers
@@ -453,11 +952,11 @@ func (a *App) handleHomeMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
if row >= 0 && row < len(hosts) {
a.serverCursor = row
}
} else if x >= runX0 {
// Clicked in the recent runs panel
} else if x >= rightX0 && y >= runsRowY0 {
// Clicked in the recent runs panel (below server detail in right column)
a.activePanel = PanelRuns
// Each run entry is 3 lines: name, host/status, blank
row := (y - serverRowY0) / 3
// Each run entry is 3 lines: line1, line2, blank
row := (y - runsRowY0) / 3
if row >= 0 && row < len(a.runs) {
a.runCursor = row
}
@@ -466,6 +965,53 @@ func (a *App) handleHomeMouseMsg(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
return a, nil
}
// hostsForJob returns the inventory hosts targeted by a compliance job.
func hostsForJob(inv *inventory.Inventory, job compliance.Job) []*inventory.Host {
if job.Limit == "all" {
return inv.Hosts
}
var out []*inventory.Host
for _, h := range inv.Hosts {
if h.Name == job.Limit || h.Group == job.Limit {
out = append(out, h)
}
}
return out
}
// expandGroupJobsPerHost replaces each group-level job with one job per host in
// that group. Used for apply runs so changes roll out serially within a group.
// Universal jobs (Group == "") are passed through unchanged.
func expandGroupJobsPerHost(jobs []compliance.Job, inv *inventory.Inventory) []compliance.Job {
expanded := make([]compliance.Job, 0, len(jobs))
for _, job := range jobs {
if job.Group == "" {
expanded = append(expanded, job)
continue
}
var hosts []string
for _, h := range inv.Hosts {
if h.Group == job.Group {
hosts = append(hosts, h.Name)
}
}
sort.Strings(hosts)
if len(hosts) == 0 {
expanded = append(expanded, job) // no hosts known, keep as-is
continue
}
for _, name := range hosts {
expanded = append(expanded, compliance.Job{
Playbook: job.Playbook,
Tags: job.Tags,
Limit: name,
Group: job.Group,
})
}
}
return expanded
}
// padRight pads s to exactly width runes (truncates if longer).
func padRight(s string, width int) string {
r := []rune(s)
@@ -477,3 +1023,11 @@ func padRight(s string, width int) string {
}
return s + strings.Repeat(" ", width-len(r))
}
func padANSI(s string, width int) string {
w := lipgloss.Width(s)
if w >= width {
return s
}
return s + strings.Repeat(" ", width-w)
}
+128 -19
View File
@@ -2,14 +2,15 @@ package ui
import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
)
var modeOptions = []ansible.Mode{
@@ -23,6 +24,9 @@ func (a *App) updateCmdFlow(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.flowStep == StepExecuting || a.flowStep == StepDone {
if km, ok := msg.(tea.KeyMsg); ok {
switch {
case key.Matches(km, keys.Logs):
a.showRunLogs = !a.showRunLogs
return a, nil
case key.Matches(km, keys.Back):
if a.flowStep == StepExecuting && a.cancelRun != nil {
a.cancelRun()
@@ -93,7 +97,7 @@ func (a *App) updateCmdFlow(msg tea.Msg) (tea.Model, tea.Cmd) {
switch a.flowStep {
case StepPlaybook:
if len(a.playbookList) == 0 {
a.formErr = fmt.Sprintf("no playbooks found in %s", a.cfg.PlaybookDir)
a.formErr = fmt.Sprintf("no playbooks found in %s", a.cfg.EffectivePlaybookDir())
return a, nil
}
a.flowPlaybook = a.playbookList[a.pbCursor]
@@ -104,8 +108,8 @@ func (a *App) updateCmdFlow(msg tea.Msg) (tea.Model, tea.Cmd) {
case StepMode:
a.flowMode = modeOptions[a.modeCursor]
a.flowArgv = ansible.BuildPlaybookArgs(
a.cfg.InventoryPath,
filepath.Join(a.cfg.PlaybookDir, a.flowPlaybook),
a.cfg.EffectiveInventoryPath(),
a.flowPlaybook,
a.flowHost,
a.flowMode,
)
@@ -129,8 +133,15 @@ func (a *App) viewCmdFlow() string {
w = 20
}
title := titleStyle.Render(fmt.Sprintf("ansibleTUI / Check + Apply %s", a.flowHost))
titleText := fmt.Sprintf("ansibleTUI / Check + Apply - %s", a.flowHost)
if a.complianceMode {
titleText = "ansibleTUI / Compliance Scan"
}
title := titleStyle.Render(titleText)
bar := a.renderStepBar()
if a.complianceMode {
bar = ""
}
var body string
switch a.flowStep {
@@ -171,8 +182,8 @@ func (a *App) renderStepBar() string {
func (a *App) renderPlaybookStep(w int) string {
header := panelHeaderStyle.Render("Select playbook")
if len(a.playbookList) == 0 {
msg := formErrorStyle.Render("No playbooks found in: " + a.cfg.PlaybookDir)
hint := "\n" + dimStyle.Render("Set playbook_dir in ~/.config/ansibletui/config.yaml")
msg := formErrorStyle.Render("No playbooks found in: " + a.cfg.EffectivePlaybookDir())
hint := "\n" + dimStyle.Render("Set playbooks_git or playbook_dir in ~/.config/ansibletui/ansibletui.yaml")
return panelStyle.Width(w - 2).Render(header + "\n" + msg + hint)
}
@@ -244,10 +255,13 @@ func (a *App) renderPreviewStep(w int) string {
}
func (a *App) renderExecutingStep(w int) string {
if a.complianceMode {
return a.renderComplianceRunStep(w, false)
}
header := panelHeaderStyle.Render(
fmt.Sprintf("Running %s on %s", a.flowPlaybook, a.flowHost),
fmt.Sprintf("Running - %s on %s", a.flowPlaybook, a.flowHost),
)
spinner := navActiveStyle.Render(" ")
spinner := navActiveStyle.Render("* ")
a.outputVp.Width = w - 6
vpH := a.height - 14
if vpH < 3 {
@@ -255,20 +269,43 @@ func (a *App) renderExecutingStep(w int) string {
}
a.outputVp.Height = vpH
content := header + "\n" + spinner + subtleStyle.Render("executing…") + "\n\n" + a.outputVp.View()
lines := []string{
header,
spinner + subtleStyle.Render("executing"),
renderRunProgress(0, 1, w-8),
subtleStyle.Render("Mode: ") + boldStyle.Render(a.flowMode.ModeLabel()),
subtleStyle.Render("Target: ") + boldStyle.Render(a.flowHost),
subtleStyle.Render("Elapsed: ") + dimStyle.Render(time.Since(a.runStarted).Round(time.Second).String()),
"",
dimStyle.Render("Raw logs are hidden. Press o to toggle output."),
}
if a.showRunLogs {
lines = append(lines, "", panelHeaderStyle.Render("Live Output"), a.outputVp.View())
}
content := strings.Join(lines, "\n")
return panelStyle.Width(w - 2).Render(content)
}
func (a *App) renderDoneStep(w int) string {
if a.complianceMode {
return a.renderComplianceRunStep(w, true)
}
rec := a.flowRecord
if rec == nil {
return panelStyle.Width(w - 2).Render(formErrorStyle.Render("Run failed — no result recorded."))
}
statusStr := renderRunStatus(rec.Status)
header := panelHeaderStyle.Render("Run complete — " + rec.Playbook)
if rec.Mode == "check" || rec.Mode == "check+diff" {
statusStr = renderRunStatusForCheck(rec.Status, rec.Changed)
}
header := panelHeaderStyle.Render("Run complete - " + rec.Playbook)
recap := fmt.Sprintf("ok=%d changed=%d failed=%d unreachable=%d skipped=%d",
rec.OK, rec.Changed, rec.Failed, rec.Unreachable, rec.Skipped)
recapNote := ""
if a.flowMode.IsCheckMode() {
recapNote = "\n" + dimStyle.Render("Dry run (--check): changed=N means N tasks would change the host; nothing was applied.")
}
a.outputVp.Width = w - 6
vpH := a.height - 18
@@ -277,22 +314,94 @@ func (a *App) renderDoneStep(w int) string {
}
a.outputVp.Height = vpH
content := header + "\n" +
subtleStyle.Render("Status: ") + statusStr + "\n" +
subtleStyle.Render(recap) + "\n\n" +
a.outputVp.View() + "\n\n" +
dimStyle.Render("Press Esc to return to home")
lines := []string{
header,
subtleStyle.Render("Mode: ") + boldStyle.Render(a.flowMode.ModeLabel()) + "\n" +
subtleStyle.Render("Status: ") + statusStr + "\n" +
subtleStyle.Render(recap) + recapNote,
renderRunProgress(1, 1, w-8),
dimStyle.Render("Press o to toggle raw output, Esc to return to home"),
}
if a.showRunLogs {
lines = append(lines, "", panelHeaderStyle.Render("Output log"), a.outputVp.View())
}
return panelStyle.Width(w - 2).Render(content)
return panelStyle.Width(w - 2).Render(strings.Join(lines, "\n"))
}
func (a *App) renderComplianceRunStep(w int, done bool) string {
rec := a.flowRecord
completed := a.complianceIndex
if done {
completed = a.complianceTotal
}
header := panelHeaderStyle.Render("Compliance Status")
current := "complete"
if !done {
current = fmt.Sprintf("%s (%s)", a.complianceJob.Playbook, a.complianceJob.Label())
}
summary := a.complianceSummary
if done && rec != nil && summary.Status == "" {
summary.Status = rec.Status
summary.OK = rec.OK
summary.Changed = rec.Changed
summary.Failed = rec.Failed
summary.Unreachable = rec.Unreachable
summary.Skipped = rec.Skipped
}
status := renderRunStatus(summary.Status)
if summary.Status == "" {
status = subtleStyle.Render("running")
}
lines := []string{
header,
renderRunProgress(completed, a.complianceTotal, w-8),
subtleStyle.Render("Current: ") + boldStyle.Render(current),
subtleStyle.Render("Progress: ") + dimStyle.Render(fmt.Sprintf("%d/%d checks", completed, a.complianceTotal)),
subtleStyle.Render("Status: ") + status,
subtleStyle.Render("Recap: ") + dimStyle.Render(summary.RecapLine()),
subtleStyle.Render("Elapsed: ") + dimStyle.Render(time.Since(a.runStarted).Round(time.Second).String()),
}
if len(summary.ChangedHosts) > 0 {
lines = append(lines, subtleStyle.Render("Drifted: ")+runChangedStyle.Render(strings.Join(summary.ChangedHosts, ", ")))
}
lines = append(lines, "", dimStyle.Render("Raw logs are hidden. Press o to toggle output."))
if done {
lines = append(lines, dimStyle.Render("Press Esc to return to home."))
}
a.outputVp.Width = w - 6
vpH := a.height - 18
if vpH < 3 {
vpH = 3
}
a.outputVp.Height = vpH
if a.showRunLogs {
lines = append(lines, "", panelHeaderStyle.Render("Live Output"), a.outputVp.View())
}
return panelStyle.Width(w - 2).Render(strings.Join(lines, "\n"))
}
func renderRunProgress(done, total, width int) string {
if width > 48 {
width = 48
}
if width < 10 {
width = 10
}
bar := compliance.ProgressBar(done, total, width)
return " " + navActiveStyle.Render(bar)
}
func (a *App) renderCmdFlowFooter(w int) string {
var hints string
switch a.flowStep {
case StepExecuting:
hints = hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("cancel")
hints = hintKeyStyle.Render("o") + " " + hintDescStyle.Render("logs") + " " +
hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("cancel")
case StepDone:
hints = hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("back to home")
hints = hintKeyStyle.Render("o") + " " + hintDescStyle.Render("logs") + " " +
hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("back to home")
default:
hints = hintKeyStyle.Render("↑↓") + " " + hintDescStyle.Render("select") + " " +
hintKeyStyle.Render("Enter") + " " + hintDescStyle.Render("confirm") + " " +
+334
View File
@@ -0,0 +1,334 @@
package ui
import (
"context"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"ansibletui/internal/config"
"ansibletui/internal/git"
)
const (
configFieldRemote = 0
configFieldBranch = 1
configFieldPath = 2
configFieldOnMissing = 3
configFieldFile = 4
)
func (a *App) configGitRepo() *config.GitRepo {
if a.configRepoFocus == 0 {
return a.cfg.PlaybooksGit
}
return a.cfg.InventoryGit
}
func (a *App) configFieldCount() int {
if a.configRepoFocus == 1 {
return 5 // includes file
}
return 4
}
func (a *App) configFieldLabel(idx int) string {
switch idx {
case configFieldRemote:
return "remote"
case configFieldBranch:
return "branch"
case configFieldPath:
return "path"
case configFieldOnMissing:
return "on_missing"
case configFieldFile:
return "file"
default:
return ""
}
}
func (a *App) configFieldValue(repo *config.GitRepo, idx int) string {
if repo == nil {
return ""
}
switch idx {
case configFieldRemote:
return repo.Remote
case configFieldBranch:
return repo.Branch
case configFieldPath:
return repo.Path
case configFieldOnMissing:
return repo.OnMissing
case configFieldFile:
return repo.File
default:
return ""
}
}
func (a *App) setConfigFieldValue(repo *config.GitRepo, idx int, val string) {
val = strings.TrimSpace(val)
switch idx {
case configFieldRemote:
repo.Remote = val
case configFieldBranch:
repo.Branch = val
case configFieldPath:
repo.Path = val
case configFieldOnMissing:
repo.OnMissing = strings.ToLower(val)
case configFieldFile:
repo.File = val
}
}
func (a *App) openConfigFieldEdit() {
repo := a.configGitRepo()
if repo == nil {
return
}
idx := a.configFieldFocus
label := a.configFieldLabel(idx)
a.configEditInput.SetValue(a.configFieldValue(repo, idx))
a.configEditInput.Placeholder = configFieldPlaceholder(idx)
a.configEditInput.Prompt = label + ": "
w := a.width - 12
if w < 40 {
w = 40
}
if w > 100 {
w = 100
}
a.configEditInput.Width = w
a.configEditInput.Focus()
a.configEditing = true
}
func configFieldPlaceholder(idx int) string {
switch idx {
case configFieldRemote:
return "git@github.com:you/repo.git"
case configFieldBranch:
return "main"
case configFieldPath:
return "~/.ansibletui/playbooks"
case configFieldOnMissing:
return "clone or init"
case configFieldFile:
return "inventory.yml"
default:
return ""
}
}
func (a *App) saveConfigFieldEdit() error {
repo := a.configGitRepo()
if repo == nil {
return nil
}
a.setConfigFieldValue(repo, a.configFieldFocus, a.configEditInput.Value())
a.cfg.Normalize()
a.configEditing = false
a.configEditInput.Blur()
return a.cfg.Save()
}
func (a *App) cancelConfigFieldEdit() {
a.configEditing = false
a.configEditInput.Blur()
}
func refreshGitStatusCmd(cfg *config.Config) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var pb, inv string
if cfg.PlaybooksGit != nil && cfg.PlaybooksGit.Enabled {
pb = git.ShortStatus(ctx, cfg.PlaybooksGit.Path)
} else {
pb = "disabled"
}
if cfg.InventoryGit != nil && cfg.InventoryGit.Enabled {
inv = git.ShortStatus(ctx, cfg.InventoryGit.Path)
} else {
inv = "disabled"
}
return gitStatusMsg{playbooks: pb, inventory: inv}
}
}
func gitSyncCmd(cfg *config.Config, repo string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
var summary string
var err error
switch repo {
case "playbooks":
summary, err = cfg.SyncPlaybooks(ctx)
case "inventory":
summary, err = cfg.SyncInventory(ctx)
default:
err = context.Canceled
}
return gitOpDoneMsg{repo: repo, op: "sync", summary: summary, err: err}
}
}
func gitPushCmd(cfg *config.Config, repo string) tea.Cmd {
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
var summary string
var err error
switch repo {
case "playbooks":
summary, err = cfg.PushPlaybooks(ctx)
case "inventory":
summary, err = cfg.PushInventory(ctx)
default:
err = context.Canceled
}
return gitOpDoneMsg{repo: repo, op: "push", summary: summary, err: err}
}
}
func (a *App) updateConfigTab(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.configEditing {
return a.updateConfigFieldEdit(msg)
}
km, ok := msg.(tea.KeyMsg)
if !ok {
return a, nil
}
switch {
case key.Matches(km, keys.Quit):
return a, tea.Quit
case key.Matches(km, keys.Up):
if a.configRepoFocus > 0 {
a.configRepoFocus--
a.configFieldFocus = configFieldRemote
}
return a, nil
case key.Matches(km, keys.Down):
if a.configRepoFocus < 1 {
a.configRepoFocus++
a.configFieldFocus = configFieldRemote
}
return a, nil
case key.Matches(km, keys.Left):
if a.configFieldFocus > 0 {
a.configFieldFocus--
}
return a, nil
case key.Matches(km, keys.Right):
if a.configFieldFocus < a.configFieldCount()-1 {
a.configFieldFocus++
}
return a, nil
case key.Matches(km, keys.Edit), key.Matches(km, keys.Enter):
a.openConfigFieldEdit()
return a, textinput.Blink
case key.Matches(km, keys.Toggle):
if a.configRepoFocus == 0 && a.cfg.PlaybooksGit != nil {
a.cfg.PlaybooksGit.Enabled = !a.cfg.PlaybooksGit.Enabled
} else if a.cfg.InventoryGit != nil {
a.cfg.InventoryGit.Enabled = !a.cfg.InventoryGit.Enabled
}
_ = a.cfg.Save()
return a, refreshGitStatusCmd(a.cfg)
case key.Matches(km, keys.Sync):
a.errMsg = ""
if a.configSyncing {
return a, nil
}
repo := "inventory"
if a.configRepoFocus == 0 {
repo = "playbooks"
}
a.configSyncing = true
a.statusMsg = "syncing " + repo + "…"
return a, gitSyncCmd(a.cfg, repo)
case key.Matches(km, keys.Push):
if a.configSyncing {
return a, nil
}
repo := "inventory"
if a.configRepoFocus == 0 {
repo = "playbooks"
}
a.configSyncing = true
a.statusMsg = "pushing " + repo + "…"
return a, gitPushCmd(a.cfg, repo)
}
return a, nil
}
func (a *App) clampConfigFieldFocus() {
max := a.configFieldCount() - 1
if a.configFieldFocus > max {
a.configFieldFocus = max
}
}
func (a *App) updateConfigFieldEdit(msg tea.Msg) (tea.Model, tea.Cmd) {
km, ok := msg.(tea.KeyMsg)
if ok {
switch {
case key.Matches(km, keys.Back):
a.cancelConfigFieldEdit()
return a, nil
case key.Matches(km, keys.Enter):
if err := a.saveConfigFieldEdit(); err != nil {
a.errMsg = err.Error()
} else {
a.statusMsg = "saved " + a.configFieldLabel(a.configFieldFocus)
a.errMsg = ""
}
return a, refreshGitStatusCmd(a.cfg)
}
}
var cmd tea.Cmd
a.configEditInput, cmd = a.configEditInput.Update(msg)
return a, cmd
}
func (a *App) configTabEnterCmd() tea.Cmd {
a.configFieldFocus = configFieldRemote
return refreshGitStatusCmd(a.cfg)
}
// renderConfigFieldLine renders one editable git config field for the config tab.
func (a *App) renderConfigFieldLine(repo *config.GitRepo, idx int, repoSelected bool) string {
label := a.configFieldLabel(idx)
val := a.configFieldValue(repo, idx)
if val == "" {
val = dimStyle.Render("(empty)")
}
line := " " + subtleStyle.Render(label+": ") + val
fieldActive := repoSelected && a.configFieldFocus == idx && !a.configEditing
if fieldActive {
line = " " + tableRowSelected.Render("▸ "+label+": ") + val
}
if repoSelected && a.configEditing && a.configFieldFocus == idx {
line = " " + subtleStyle.Render(label+": ") + a.configEditInput.View()
}
return line
}
+31
View File
@@ -0,0 +1,31 @@
package ui
import (
"testing"
"ansibletui/internal/ansible"
"ansibletui/internal/config"
"ansibletui/internal/history"
"ansibletui/internal/inventory"
)
func TestApplyFindingsSetsAnsibleDiagnosticWithoutReachabilityProbe(t *testing.T) {
inv := &inventory.Inventory{Hosts: []*inventory.Host{{Name: "kube-1", DriftState: "unreachable"}}}
app := New(&config.Config{}, inv, history.New(t.TempDir()))
app.applyFindings([]ansible.Finding{{
Host: "kube-1",
Status: "unreachable",
Summary: "Task failed: Failed to create temporary directory",
Diagnostic: "Ansible bootstrap failed: cannot create remote tmp directory",
}}, nil)
if got := inv.Hosts[0].LastError; got != "Ansible bootstrap failed: cannot create remote tmp directory" {
t.Fatalf("LastError = %q", got)
}
if inv.Hosts[0].Reachable != nil {
t.Fatalf("Reachable was modified by structured findings: %#v", inv.Hosts[0].Reachable)
}
if len(app.hostFindings["kube-1"]) != 1 {
t.Fatalf("host findings = %#v", app.hostFindings)
}
}
+564 -174
View File
@@ -2,7 +2,8 @@ package ui
import (
"fmt"
"path/filepath"
"os"
"sort"
"strings"
"github.com/charmbracelet/bubbles/key"
@@ -10,6 +11,9 @@ import (
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
"ansibletui/internal/config"
"ansibletui/internal/inventory"
)
// ---- Update ----
@@ -20,6 +24,13 @@ func (a *App) updateHome(msg tea.Msg) (tea.Model, tea.Cmd) {
return a.updateFilter(msg)
}
if a.sidebarTab == TabConfig {
if km, ok := msg.(tea.KeyMsg); ok && key.Matches(km, keys.Quit) {
return a, tea.Quit
}
return a.updateConfigTab(msg)
}
km, ok := msg.(tea.KeyMsg)
if !ok {
return a, nil
@@ -30,11 +41,21 @@ func (a *App) updateHome(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Quit
case key.Matches(km, keys.Tab):
prev := a.sidebarTab
a.sidebarTab = (a.sidebarTab + 1) % 3
a.syncActivePanelToTab()
if a.sidebarTab == TabConfig && prev != TabConfig {
return a, a.configTabEnterCmd()
}
return a, nil
case key.Matches(km, keys.ShiftTab):
prev := a.sidebarTab
a.sidebarTab = (a.sidebarTab + 2) % 3
a.syncActivePanelToTab()
if a.sidebarTab == TabConfig && prev != TabConfig {
return a, a.configTabEnterCmd()
}
return a, nil
// Panel switch with Left/Right — clear messages when switching panels
@@ -84,31 +105,40 @@ func (a *App) updateHome(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(km, keys.Ping):
if h := a.selectedServer(); h != nil {
a.statusMsg = fmt.Sprintf("pinging %s…", h.Name)
return a, pingCmd(a.cfg.InventoryPath, h.Name)
addr := h.AnsibleHost
if addr == "" {
addr = h.Name
}
a.statusMsg = fmt.Sprintf("probing %s…", h.Name)
return a, pingCmd(h.Name, addr, h.AnsiblePort)
}
return a, nil
case key.Matches(km, keys.Check):
if h := a.selectedServer(); h != nil {
a.openCmdFlow(h.Name, ansible.ModeCheckDiff)
}
return a, nil
return a.openComplianceScan()
case key.Matches(km, keys.Fix):
return a.openComplianceApply()
case key.Matches(km, keys.Apply):
if a.complianceRunning {
a.errMsg = "compliance run already in progress"
a.statusMsg = ""
return a, nil
}
if h := a.selectedServer(); h != nil {
a.openCmdFlow(h.Name, ansible.ModeApply)
}
return a, nil
case key.Matches(km, keys.Enter):
if a.activePanel == PanelRuns {
if a.activePanel == PanelRuns || a.sidebarTab == TabJobs {
if run := a.selectedRunRecord(); run != nil {
a.openRunDetailsScreen(run)
}
} else {
if h := a.selectedServer(); h != nil {
a.openCmdFlow(h.Name, ansible.ModeCheckDiff)
a.openHostDetailsScreen(h.Name)
}
}
return a, nil
@@ -163,60 +193,113 @@ func (a *App) moveCursorDown() {
func (a *App) openCmdFlow(host string, defaultMode ansible.Mode) {
a.screen = ScreenCmdFlow
a.complianceMode = false
a.complianceJobs = nil
a.complianceSummary = compliance.Summary{}
a.flowHost = host
a.flowMode = defaultMode
a.flowStep = StepPlaybook
a.outputLines = nil
a.flowRecord = nil
a.flowLog = nil
a.showRunLogs = false
a.modeCursor = int(defaultMode)
a.loadPlaybooks()
}
func (a *App) openComplianceScan() (tea.Model, tea.Cmd) {
jobs := compliance.Plan(a.cfg.Compliance, a.inv)
if len(jobs) == 0 {
a.errMsg = "no compliance playbooks mapped; use Enter for a manual check or add compliance mappings to config"
a.statusMsg = ""
return a, nil
}
if a.complianceRunning {
a.errMsg = "compliance run already in progress"
a.statusMsg = ""
return a, nil
}
a.errMsg = ""
a.statusMsg = fmt.Sprintf("starting compliance scan: %d mapped checks", len(jobs))
return a, a.startComplianceRun(jobs, ansible.ModeCheckDiff, true, "compliance scan")
}
func (a *App) openComplianceApply() (tea.Model, tea.Cmd) {
jobs := compliance.Plan(a.cfg.Compliance, a.inv)
if len(jobs) == 0 {
a.errMsg = "no compliance playbooks mapped; add compliance.yaml or config compliance mappings"
a.statusMsg = ""
return a, nil
}
if a.complianceRunning {
a.errMsg = "compliance run already in progress"
a.statusMsg = ""
return a, nil
}
a.errMsg = ""
a.statusMsg = fmt.Sprintf("applying compliance: %d mapped playbooks", len(jobs))
return a, a.startComplianceRun(jobs, ansible.ModeApply, true, "compliance apply")
}
// ---- View ----
func (a *App) viewHome() string {
mainW := a.width - 2
mainW := a.width
if mainW < 20 {
mainW = 20
}
titleBar := a.renderTitleBar(mainW)
body := a.renderBody(mainW)
footer := a.renderFooter(mainW)
titleH := lipgloss.Height(titleBar)
footerH := lipgloss.Height(footer)
bodyH := a.height - titleH - footerH
if bodyH < 6 {
bodyH = 6
}
body := a.renderBody(mainW, bodyH)
// Clamp body to exactly bodyH lines to prevent terminal scroll from pushing
// title bar off-screen when panel content overflows its allocated height.
if lines := strings.Split(body, "\n"); len(lines) > bodyH {
body = strings.Join(lines[:bodyH], "\n")
}
return lipgloss.JoinVertical(lipgloss.Left, titleBar, body, footer)
}
func (a *App) renderTitleBar(w int) string {
title := titleStyle.Render("ansibleTUI / Home")
inv := pathStyle.Render("inventory: " + shorten(a.cfg.InventoryPath))
pbs := pathStyle.Render("playbooks: " + shorten(a.cfg.PlaybookDir))
title := titleStyle.Render("ansibleTUI")
inv := pathStyle.Render("inv: " + shorten(a.cfg.EffectiveInventoryPath()))
pbs := pathStyle.Render("pb: " + shorten(a.cfg.EffectivePlaybookDir()))
right := inv + " " + pbs
gap := w - lipgloss.Width(title) - lipgloss.Width(right)
if gap < 1 {
gap = 1
}
return " " + title + strings.Repeat(" ", gap) + right
content := title + strings.Repeat(" ", gap) + right
border := dividerStyle.Render(strings.Repeat("─", w))
return content + "\n" + border
}
func (a *App) renderBody(w int) string {
func (a *App) renderBody(w, bodyH int) string {
sideW := 6
mainW := w - sideW - 1
if mainW < 10 {
mainW = 10
}
sidebar := a.renderSidebar(sideW)
main := a.renderMainContent(mainW)
sidebar := a.renderSidebar(sideW, bodyH)
main := a.renderMainContent(mainW, bodyH)
return lipgloss.JoinHorizontal(lipgloss.Top, sidebar, main)
}
func (a *App) renderSidebar(w int) string {
func (a *App) renderSidebar(w, h int) string {
tabs := []string{"SERV", "JOBS", "CFG"}
var lines []string
lines = append(lines, "") // top padding
for i, t := range tabs {
if i == a.sidebarTab {
lines = append(lines, navActiveStyle.Width(w).Render(t))
@@ -225,199 +308,321 @@ func (a *App) renderSidebar(w int) string {
}
lines = append(lines, "")
}
return strings.Join(lines, "\n")
content := strings.Join(lines, "\n")
return lipgloss.Place(w, h, lipgloss.Left, lipgloss.Top, content)
}
func (a *App) renderMainContent(w int) string {
func (a *App) renderMainContent(w, bodyH int) string {
switch a.sidebarTab {
case TabJobs:
return a.renderJobsTab(w)
return a.renderJobsTab(w, bodyH)
case TabConfig:
return a.renderConfigTab(w)
return a.renderConfigTab(w, bodyH)
default:
return a.renderServersTab(w)
return a.renderServersTab(w, bodyH)
}
}
func (a *App) renderServersTab(w int) string {
// Top row: Fleet Summary | Quick Actions
topH := 7
fleetW := w * 33 / 100
if fleetW < 28 {
fleetW = 28
func (a *App) renderServersTab(w, bodyH int) string {
statusBar := a.renderStatusBar(w)
statusH := lipgloss.Height(statusBar)
panelH := bodyH - statusH
if panelH < 6 {
panelH = 6
}
actionW := w - fleetW - 1
fleet := a.renderFleetSummary(fleetW, topH)
actions := a.renderQuickActions(actionW, topH)
topRow := lipgloss.JoinHorizontal(lipgloss.Top, fleet, " ", actions)
// Bottom row: Servers table | Recent runs
bottomH := a.height - topH - 5 // leave room for title, footer, status
if bottomH < 6 {
bottomH = 6
topH := 8
if panelH < 18 {
topH = 6
}
serversW := w * 65 / 100
contentH := panelH - topH
if contentH < 8 {
contentH = 8
}
serversW := w * 66 / 100
if serversW < 40 {
serversW = 40
}
runsW := w - serversW - 1
rightW := w - serversW - 1
servers := a.renderServersPanel(serversW, bottomH)
runs := a.renderRecentRunsPanel(runsW, bottomH)
bottomRow := lipgloss.JoinHorizontal(lipgloss.Top, servers, " ", runs)
detailH := contentH / 2
if detailH < 8 {
detailH = 8
}
runsH := contentH - detailH
if runsH < 4 {
runsH = 4
}
statusBar := a.renderStatusBar(w)
top := lipgloss.JoinHorizontal(lipgloss.Top,
a.renderFleetSummaryPanel(serversW, topH),
" ",
a.renderQuickActionsPanel(rightW, topH),
)
servers := a.renderServersPanel(serversW, contentH)
detail := a.renderServerDetail(rightW, detailH)
runs := a.renderRecentRunsPanel(rightW, runsH)
right := lipgloss.JoinVertical(lipgloss.Left, detail, runs)
return lipgloss.JoinVertical(lipgloss.Left, topRow, bottomRow, statusBar)
panels := lipgloss.JoinHorizontal(lipgloss.Top, servers, " ", right)
return lipgloss.JoinVertical(lipgloss.Left, top, panels, statusBar)
}
func (a *App) renderFleetSummary(w, h int) string {
total := len(a.inv.Hosts)
var nClean, nDrift, nFailed int
for _, h := range a.inv.Hosts {
switch h.DriftState {
func (a *App) renderFleetSummaryPanel(w, h int) string {
total, clean, drift, failed, _ := fleetCounts(a.inv.Hosts)
checks := len(compliance.Plan(a.cfg.Compliance, a.inv))
runningLine := ""
if a.complianceRunning {
runningLine = " " + runChangedStyle.Render("● "+a.complianceAction)
}
lines := []string{
boldStyle.Render("Fleet Summary"),
"",
badgeHostsStyle.Render(fmt.Sprintf("%d hosts", total)) + " " +
badgeCleanStyle.Render(fmt.Sprintf("%d clean", clean)) + " " +
badgeDriftStyle.Render(fmt.Sprintf("%d drift", drift)) + " " +
badgeFailedStyle.Render(fmt.Sprintf("%d failed", failed)) + runningLine,
subtleStyle.Render(fmt.Sprintf("%d mapped compliance checks", checks)),
}
return panelStyle.Width(max(1, w-4)).Height(max(1, h-2)).Render(strings.Join(lines, "\n"))
}
func (a *App) renderQuickActionsPanel(w, h int) string {
// When a compliance scan has results, show a per-job breakdown.
// When idle, show keyboard hints.
if len(a.complianceJobResults) > 0 {
return a.renderScanResultsPanel(w, h)
}
hint := func(k, d string) string {
return hintKeyStyle.Render(k) + " " + hintDescStyle.Render(d)
}
// Keep each hint line short enough to avoid wrapping in the narrow right panel.
// Visible chars per line stay under 20 to fit on 80-col terminals.
lines := []string{
boldStyle.Render("Quick Actions"),
"",
hint("c", "scan") + " " + hint("f", "fix"),
hint("r", "apply") + " " + hint("Enter", "flow"),
}
return panelStyle.Width(max(1, w-4)).Height(max(1, h-2)).Render(strings.Join(lines, "\n"))
}
func (a *App) renderScanResultsPanel(w, h int) string {
innerW := max(1, w-4)
// Status icon + colour for each outcome.
statusStr := func(status string) string {
switch status {
case "clean":
nClean++
return runCleanStyle.Render("✓ clean")
case "drift":
nDrift++
return runChangedStyle.Render("~ drift")
case "failed", "unreachable":
nFailed++
return runFailedStyle.Render("✗ " + status)
default:
return dimStyle.Render(status)
}
}
header := panelHeaderStyle.Render("Fleet Summary")
badges := lipgloss.JoinHorizontal(lipgloss.Top,
badgeHostsStyle.Render(fmt.Sprintf("%d hosts", total))+" ",
badgeCleanStyle.Render(fmt.Sprintf("%d clean", nClean))+" ",
badgeDriftStyle.Render(fmt.Sprintf("%d drift", nDrift))+" ",
badgeFailedStyle.Render(fmt.Sprintf("%d failed", nFailed)),
)
running := a.complianceRunning
content := header + "\n" + badges
innerW := w - 4 // account for border+padding
if innerW < 1 {
innerW = 1
var header string
if running {
header = boldStyle.Render("Scan Results") + " " + dimStyle.Render("running…")
} else {
header = boldStyle.Render("Scan Results")
}
_ = h
return panelStyle.Width(innerW).Render(content)
lines := []string{header, ""}
// How many result rows fit: inner height minus header(1) + blank(1).
maxRows := (h - 2) - 2
if maxRows < 1 {
maxRows = 1
}
results := a.complianceJobResults
shown := results
overflow := 0
if len(results) > maxRows {
shown = results[:maxRows-1]
overflow = len(results) - (maxRows - 1)
}
pbW := innerW - 12 // leave room for status column
if pbW < 6 {
pbW = 6
}
for _, r := range shown {
// Compact playbook display: trim leading "playbooks/" and extension.
pb := r.playbook
pb = strings.TrimPrefix(pb, "playbooks/")
pb = strings.TrimSuffix(pb, ".yml")
pb = strings.TrimSuffix(pb, ".yaml")
if r.label != "all" && r.label != "" {
pb = pb + "/" + r.label
}
pb = padRight(pb, pbW)
lines = append(lines, pb+" "+statusStr(r.status))
}
if overflow > 0 {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" … %d more (see JOBS tab)", overflow)))
} else if len(results) == 0 && running {
lines = append(lines, dimStyle.Render("waiting for jobs…"))
}
return panelStyle.Width(innerW).Height(max(1, h-2)).Render(strings.Join(lines, "\n"))
}
func (a *App) renderQuickActions(w, h int) string {
header := panelHeaderStyle.Render("Quick Actions")
btn := func(label string) string {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorBorder).
Padding(0, 1).
Foreground(colorSubtle).
Render(label)
}
btns := lipgloss.JoinHorizontal(lipgloss.Top,
btn("Check Selected")+" ",
btn("Apply Drifted")+" ",
btn("Add Server")+" ",
btn("Open Logs"),
)
preview := ""
if h := a.selectedServer(); h != nil && a.cfg.RecentPlaybook != "" {
argv := ansible.BuildPlaybookArgs(
a.cfg.InventoryPath,
filepath.Join(a.cfg.PlaybookDir, a.cfg.RecentPlaybook),
h.Name,
ansible.ModeCheckDiff,
)
cmdStr := strings.Join(argv, " ")
if len(cmdStr) > w-8 {
cmdStr = cmdStr[:w-8] + "…"
}
preview = "\n" + dimStyle.Render("$ "+cmdStr)
}
content := header + "\n" + btns + preview
func (a *App) renderServerDetail(w, h int) string {
header := panelHeaderStyle.Render("Server Detail")
innerW := w - 4
if innerW < 1 {
innerW = 1
}
_ = h
return panelStyle.Width(innerW).Render(content)
host := a.selectedServer()
if host == nil {
content := header + "\n" + dimStyle.Render("No server selected.")
return panelStyle.Width(innerW).Height(h - 2).Render(content)
}
label := lipgloss.NewStyle().Foreground(colorSubtle).Width(9).Render
var lines []string
lines = append(lines, lipgloss.NewStyle().Bold(true).Foreground(colorCyan).Render(host.Name))
if host.AnsibleHost != "" {
lines = append(lines, label("IP")+" "+dimStyle.Render(host.AnsibleHost))
}
if host.Group != "" {
lines = append(lines, label("Group")+" "+subtleStyle.Render(host.Group))
}
lines = append(lines, label("Status")+" "+renderReachable(host.Reachable))
lines = append(lines, label("Drift")+" "+renderDrift(host.DriftState))
if host.LastCheck != "" {
lines = append(lines, label("Checked")+" "+dimStyle.Render(host.LastCheck))
}
if host.LastApply != "" {
lines = append(lines, label("Applied")+" "+dimStyle.Render(host.LastApply))
}
if host.LastError != "" {
// Wrap the error message to the available width.
maxErrW := innerW - 11 // label width (9) + space (1) + margin
if maxErrW < 10 {
maxErrW = 10
}
errText := host.LastError
if len(errText) > maxErrW {
errText = errText[:maxErrW-1] + "…"
}
lines = append(lines, label("Error")+" "+formErrorStyle.Render(errText))
}
if a.cfg.RecentPlaybook != "" {
argv := ansible.BuildPlaybookArgs(
a.cfg.EffectiveInventoryPath(),
a.cfg.RecentPlaybook,
host.Name,
ansible.ModeCheckDiff,
)
cmdStr := strings.Join(argv, " ")
maxCmd := innerW - 4
if maxCmd < 4 {
maxCmd = 4
}
if len(cmdStr) > maxCmd {
cmdStr = cmdStr[:maxCmd] + "…"
}
lines = append(lines, "")
lines = append(lines, dimStyle.Render("$ "+cmdStr))
}
content := header + "\n" + strings.Join(lines, "\n")
return panelStyle.Width(innerW).Height(h - 2).Render(content)
}
func (a *App) renderServersPanel(w, h int) string {
header := panelHeaderStyle.Render("Servers")
// Column widths — scale with panel width
colHost := 16
colGroup := 12
colReach := 10
colDrift := 10
colCheck := 10
colApply := 10
// Header row
hdr := tableHeaderStyle.Render(
padRight("Host", colHost)+" "+
padRight("Group", colGroup)+" "+
padRight("Reachable", colReach)+" "+
padRight("Drift", colDrift)+" "+
padRight("Last Check", colCheck)+" "+
padRight("Last Apply", colApply),
)
divider := dividerStyle.Render(strings.Repeat("─", w-4))
header := panelHeaderStyle.Render("Inventory Overview")
hosts := a.filteredServers()
if a.serverCursor >= len(hosts) && len(hosts) > 0 {
a.serverCursor = len(hosts) - 1
}
visibleH := h - 7 // header + borders + divider
visibleH := h - 5
if visibleH < 1 {
visibleH = 1
}
var rows []string
// Scroll offset
start := 0
if a.serverCursor >= visibleH {
start = a.serverCursor - visibleH + 1
const nameW = 20
const ipW = 15
const stateW = 13
const driftW = 7
type row struct {
text string
hostIndex int
}
for i := start; i < len(hosts) && i < start+visibleH; i++ {
hst := hosts[i]
var rows []row
colHdr := dimStyle.Render(" " + padRight("Name", nameW) + padRight("IP", ipW) + padRight("State", stateW) + "Drift")
rows = append(rows, row{text: colHdr, hostIndex: -1})
reachStr := renderReachable(hst.Reachable)
driftStr := renderDrift(hst.DriftState)
selectedLine := 0
grouped := groupHosts(hosts)
hostIdx := 0
check := subtleStyle.Render(padRight(hst.LastCheck, colCheck))
apply := subtleStyle.Render(padRight(hst.LastApply, colApply))
// Build the plain-text parts (for width calculation)
plainRow := padRight(hst.Name, colHost) + " " +
padRight(hst.Group, colGroup) + " "
// Styled status columns
reachPad := padRight("", colReach-lipgloss.Width(reachStr))
driftPad := padRight("", colDrift-lipgloss.Width(driftStr))
row := plainRow + reachStr + reachPad + " " + driftStr + driftPad + " " + check + " " + apply
if i == a.serverCursor && a.activePanel == PanelServers {
// Highlight: wrap plain parts only (lipgloss handles ANSI reset)
row = tableRowSelected.Render(padRight(hst.Name, colHost)+" "+padRight(hst.Group, colGroup)+" ") +
reachStr + reachPad + " " + driftStr + driftPad + " " + check + " " + apply
for _, group := range grouped {
label := group.name
if label == "" {
label = "ungrouped"
}
groupLine := groupHeaderStyle.Render("▸ " + label)
rows = append(rows, row{text: groupLine, hostIndex: -1})
rows = append(rows, row)
for _, hst := range group.hosts {
stateStr := padANSI(renderDrift(hst.DriftState), stateW)
driftStr := dimStyle.Render(padRight(fmt.Sprintf("%d", countHostDrift(a.hostFindings[hst.Name])), driftW))
nameIP := " " + padRight(hst.Name, nameW) + padRight(hst.AnsibleHost, ipW)
lastCheck := ""
if hst.LastCheck != "" {
lastCheck = dimStyle.Render(hst.LastCheck)
}
var line string
if hostIdx == a.serverCursor && a.activePanel == PanelServers {
line = tableRowSelected.Render(nameIP) + stateStr + driftStr + lastCheck
selectedLine = len(rows)
} else {
line = nameIP + stateStr + driftStr + lastCheck
}
rows = append(rows, row{text: line, hostIndex: hostIdx})
hostIdx++
}
}
start := 0
if selectedLine >= visibleH {
start = selectedLine - visibleH + 1
}
var lines []string
if start > 0 {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" ↑ %d more", start)))
}
for i := start; i < len(rows) && i < start+visibleH; i++ {
lines = append(lines, rows[i].text)
}
if len(hosts) == 0 {
rows = append(rows, subtleStyle.Render("No servers. Press 'a' to add one."))
lines = append(lines, subtleStyle.Render("No servers. Press 'a' to add one."))
}
content := header + "\n" + hdr + "\n" + divider + "\n" + strings.Join(rows, "\n")
content := header + "\n" + strings.Join(lines, "\n")
innerW := w - 4
if innerW < 1 {
innerW = 1
@@ -426,13 +631,39 @@ func (a *App) renderServersPanel(w, h int) string {
}
func (a *App) renderRecentRunsPanel(w, h int) string {
const linesPerRun = 3
header := panelHeaderStyle.Render("Recent Runs")
// Fit runs inside panel: border(2) + header block(~2) + padding
maxRuns := (h - 6) / linesPerRun
if maxRuns < 1 {
maxRuns = 1
}
if maxRuns > maxRecentRuns {
maxRuns = maxRecentRuns
}
if a.runCursor >= len(a.runs) && len(a.runs) > 0 {
a.runCursor = len(a.runs) - 1
}
start := 0
if a.runCursor >= maxRuns {
start = a.runCursor - maxRuns + 1
}
var lines []string
for i, run := range a.runs {
if i >= h-6 {
break
}
if start > 0 {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" ↑ %d more", start)))
}
end := start + maxRuns
if end > len(a.runs) {
end = len(a.runs)
}
for i := start; i < end; i++ {
run := a.runs[i]
timeStr := runTimeStyle.Render(padRight(run.TimeLabel(), 5))
pbStr := runPlaybookStyle.Render(run.Playbook)
line1 := timeStr + " " + pbStr
@@ -463,12 +694,23 @@ func (a *App) renderRecentRunsPanel(w, h int) string {
lines = append(lines, subtleStyle.Render("No runs yet."))
}
content := header + "\n" + strings.Join(lines, "\n")
// Hard-cap content lines so the panel cannot grow past the layout height.
maxContentLines := h - 2
if maxContentLines < 3 {
maxContentLines = 3
}
contentParts := []string{header}
contentParts = append(contentParts, lines...)
allLines := strings.Split(strings.Join(contentParts, "\n"), "\n")
if len(allLines) > maxContentLines {
allLines = allLines[:maxContentLines]
}
innerW := w - 4
if innerW < 1 {
innerW = 1
}
return panelStyle.Width(innerW).Height(h - 2).Render(content)
return panelStyle.Width(innerW).Height(h - 2).Render(strings.Join(allLines, "\n"))
}
func (a *App) renderStatusBar(w int) string {
@@ -501,10 +743,13 @@ func (a *App) renderStatusBar(w int) string {
drift = " " + statusClean.Render("clean")
case "failed", "unreachable":
drift = " " + statusFailed.Render(h.DriftState)
case "scanning":
drift = " " + statusScanning.Render("scanning…")
case "fixing":
drift = " " + statusFixing.Render("fixing…")
}
msg := statusBarStyle.Render("Selected: ") + name + ip + group + drift +
" " + dimStyle.Render("e edit d delete")
msg := statusBarStyle.Render("Selected: ") + name + ip + group + drift
return lipgloss.NewStyle().Width(w).Render(msg)
}
@@ -515,7 +760,8 @@ func (a *App) renderFooter(w int) string {
hintKeyStyle.Render("a") + " " + hintDescStyle.Render("add"),
hintKeyStyle.Render("e") + " " + hintDescStyle.Render("edit"),
hintKeyStyle.Render("p") + " " + hintDescStyle.Render("ping"),
hintKeyStyle.Render("c") + " " + hintDescStyle.Render("check"),
hintKeyStyle.Render("c") + " " + hintDescStyle.Render("scan"),
hintKeyStyle.Render("f") + " " + hintDescStyle.Render("fix"),
hintKeyStyle.Render("r") + " " + hintDescStyle.Render("apply"),
hintKeyStyle.Render("Enter") + " " + hintDescStyle.Render("details"),
hintKeyStyle.Render("q") + " " + hintDescStyle.Render("quit"),
@@ -530,12 +776,76 @@ func (a *App) renderFooter(w int) string {
Render(bar)
}
type hostGroup struct {
name string
hosts []*inventory.Host
}
func fleetCounts(hosts []*inventory.Host) (total, clean, drift, failed, unknown int) {
total = len(hosts)
for _, h := range hosts {
switch h.DriftState {
case "clean":
clean++
case "drift":
drift++
case "failed", "unreachable":
failed++
default:
unknown++
}
}
return
}
func groupHosts(hosts []*inventory.Host) []hostGroup {
byGroup := map[string][]*inventory.Host{}
for _, h := range hosts {
byGroup[h.Group] = append(byGroup[h.Group], h)
}
var names []string
for name := range byGroup {
names = append(names, name)
}
sort.Strings(names)
var groups []hostGroup
for _, name := range names {
groups = append(groups, hostGroup{name: name, hosts: byGroup[name]})
}
return groups
}
// Jobs / Config stub tabs
func (a *App) renderJobsTab(w int) string {
func (a *App) renderJobsTab(w, bodyH int) string {
header := panelHeaderStyle.Render("All Runs")
maxRows := bodyH - 6
if maxRows < 1 {
maxRows = 1
}
if a.runCursor >= len(a.runs) && len(a.runs) > 0 {
a.runCursor = len(a.runs) - 1
}
start := 0
if a.runCursor >= maxRows {
start = a.runCursor - maxRows + 1
}
var lines []string
for i, run := range a.runs {
if start > 0 {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" ↑ %d more", start)))
}
end := start + maxRows
if end > len(a.runs) {
end = len(a.runs)
}
for i := start; i < end; i++ {
run := a.runs[i]
line := fmt.Sprintf("%-5s %-22s %-12s %-10s %s",
run.TimeLabel(), run.Playbook, run.Host, run.Mode, run.StatusSummary())
if i == a.runCursor {
@@ -543,26 +853,106 @@ func (a *App) renderJobsTab(w int) string {
}
lines = append(lines, line)
}
if len(lines) == 0 {
if len(a.runs) == 0 {
lines = append(lines, subtleStyle.Render("No runs recorded yet."))
}
return panelStyle.Width(w - 2).Render(header + "\n" + strings.Join(lines, "\n"))
if end < len(a.runs) {
lines = append(lines, dimStyle.Render(fmt.Sprintf(" ↓ %d more", len(a.runs)-end)))
}
innerH := bodyH - 2
if innerH < 3 {
innerH = 3
}
return panelStyle.Width(w - 2).Height(innerH).Render(header + "\n" + strings.Join(lines, "\n"))
}
func (a *App) renderConfigTab(w int) string {
func (a *App) renderConfigTab(w, bodyH int) string {
header := panelHeaderStyle.Render("Configuration")
cfgPath, _ := config.ConfigPath()
lines := []string{
subtleStyle.Render("Playbook dir: ") + a.cfg.PlaybookDir,
subtleStyle.Render("Inventory path: ") + a.cfg.InventoryPath,
subtleStyle.Render("Config file: ") + cfgPath,
subtleStyle.Render("Playbooks: ") + a.cfg.EffectivePlaybookDir(),
subtleStyle.Render("Inventory: ") + a.cfg.EffectiveInventoryPath(),
subtleStyle.Render("Runtime: ") + a.cfg.Runtime,
subtleStyle.Render("Recent playbook:") + a.cfg.RecentPlaybook,
"",
dimStyle.Render("Edit ~/.config/ansibletui/config.yaml to change these settings."),
panelHeaderStyle.Render("Git repositories"),
}
return panelStyle.Width(w - 2).Render(header + "\n" + strings.Join(lines, "\n"))
lines = append(lines, a.renderConfigRepoBlock("Playbooks", a.cfg.PlaybooksGit, a.configGitPlaybooks, a.configRepoFocus == 0)...)
lines = append(lines, "")
lines = append(lines, a.renderConfigRepoBlock("Inventory", a.cfg.InventoryGit, a.configGitInventory, a.configRepoFocus == 1)...)
lines = append(lines, "",
dimStyle.Render("↑↓ repo ←→ field e edit s sync S push t enable"),
dimStyle.Render("Config: "+cfgPath+" · examples/ansibletui.example.yaml"),
)
if a.configSyncing {
lines = append(lines, navActiveStyle.Render("⠿ git operation in progress…"))
}
if a.errMsg != "" {
lines = append(lines, formErrorStyle.Render("✗ "+a.errMsg))
} else if a.statusMsg != "" {
lines = append(lines, subtleStyle.Render(a.statusMsg))
}
innerH := bodyH - 2
if innerH < 3 {
innerH = 3
}
return panelStyle.Width(w - 2).Height(innerH).Render(header + "\n" + strings.Join(lines, "\n"))
}
// shorten abbreviates a home-relative path for display.
func (a *App) renderConfigRepoBlock(name string, repo *config.GitRepo, status string, selected bool) []string {
if repo == nil {
return []string{subtleStyle.Render(name + ": (not configured)")}
}
enabled := "off"
if repo.Enabled {
enabled = "on"
}
prefix := " "
if selected && !a.configEditing {
prefix = tableRowSelected.Render("▶ ") + " "
}
lines := []string{
prefix + boldStyle.Render(name) + subtleStyle.Render(" ["+enabled+"]") + " " + dimStyle.Render(status),
}
fieldCount := 4
if name == "Inventory" {
fieldCount = 5
}
for i := 0; i < fieldCount; i++ {
lines = append(lines, a.renderConfigFieldLine(repo, i, selected))
}
return lines
}
// syncActivePanelToTab keeps activePanel in sync with the sidebar tab selection.
func (a *App) syncActivePanelToTab() {
switch a.sidebarTab {
case TabServers:
a.activePanel = PanelServers
case TabJobs:
a.activePanel = PanelRuns
}
}
// shorten abbreviates a path for display — ~-relative and capped at 3 tail components.
func shorten(p string) string {
if home, err := os.UserHomeDir(); err == nil && strings.HasPrefix(p, home) {
p = "~" + p[len(home):]
}
sep := string(os.PathSeparator)
parts := strings.Split(p, sep)
if len(parts) > 4 {
return "…" + sep + strings.Join(parts[len(parts)-3:], sep)
}
return p
}
+117
View File
@@ -0,0 +1,117 @@
package ui
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/key"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"ansibletui/internal/ansible"
)
func (a *App) openHostDetailsScreen(host string) {
a.screen = ScreenHostDetails
a.viewingHost = host
a.logVp.SetContent(a.renderHostFindingsLog(host))
a.logVp.GotoTop()
}
func (a *App) updateHostDetails(msg tea.Msg) (tea.Model, tea.Cmd) {
if km, ok := msg.(tea.KeyMsg); ok {
switch {
case key.Matches(km, keys.Back), key.Matches(km, keys.Quit):
a.screen = ScreenHome
a.viewingHost = ""
return a, nil
}
}
var cmd tea.Cmd
a.logVp, cmd = a.logVp.Update(msg)
return a, cmd
}
func (a *App) viewHostDetails() string {
host := a.viewingHost
if host == "" {
return "No host selected."
}
w := a.width - 2
if w < 20 {
w = 20
}
title := titleStyle.Render(fmt.Sprintf("ansibleTUI / Host Details - %s", host))
findings := a.hostFindings[host]
summary := []string{
subtleStyle.Render("Host: ") + boldStyle.Render(host),
subtleStyle.Render("Drift: ") + runChangedStyle.Render(fmt.Sprintf("%d item(s)", countHostDrift(findings))),
}
if diag := firstDiagnostic(findings); diag != "" {
summary = append(summary, subtleStyle.Render("Issue: ")+formErrorStyle.Render(diag))
}
a.logVp.Width = w - 4
vpH := a.height - 12
if vpH < 3 {
vpH = 3
}
a.logVp.Height = vpH
panel := panelStyle.Width(w - 2).Render(panelHeaderStyle.Render("Findings") + "\n" + a.logVp.View())
footer := lipgloss.NewStyle().
BorderTop(true).
BorderStyle(lipgloss.NormalBorder()).
BorderForeground(colorBorder).
Width(w).
Padding(0, 1).
Render(hintKeyStyle.Render("↑↓/PgUp/PgDn") + " " + hintDescStyle.Render("scroll") + " " +
hintKeyStyle.Render("Esc") + " " + hintDescStyle.Render("back"))
return lipgloss.JoinVertical(lipgloss.Left,
" "+title,
" "+strings.Join(summary, "\n "),
"",
panel,
footer,
)
}
func (a *App) renderHostFindingsLog(host string) string {
findings := a.hostFindings[host]
if len(findings) == 0 {
return "No structured findings recorded for this host yet."
}
var lines []string
currentGroup := ""
for _, f := range findings {
group := findingGroup(f)
if group != currentGroup {
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, group)
currentGroup = group
}
lines = append(lines, " "+ansible.CompactFindingLine(f))
if f.Summary != "" && f.Diagnostic != "" && f.Summary != f.Diagnostic {
lines = append(lines, " "+dimStyle.Render(f.Summary))
}
}
return strings.Join(lines, "\n")
}
func findingGroup(f ansible.Finding) string {
parts := []string{}
if f.Playbook != "" {
parts = append(parts, f.Playbook)
}
if len(f.Tags) > 0 {
parts = append(parts, "tags="+strings.Join(f.Tags, ","))
}
if len(parts) == 0 {
return "Findings"
}
return strings.Join(parts, " ")
}
+10
View File
@@ -13,12 +13,17 @@ type keyMap struct {
Ping key.Binding
Check key.Binding
Apply key.Binding
Fix key.Binding
Enter key.Binding
Back key.Binding
Quit key.Binding
Tab key.Binding
ShiftTab key.Binding
Delete key.Binding
Sync key.Binding
Push key.Binding
Toggle key.Binding
Logs key.Binding
}
var keys = keyMap{
@@ -32,10 +37,15 @@ var keys = keyMap{
Ping: key.NewBinding(key.WithKeys("p")),
Check: key.NewBinding(key.WithKeys("c")),
Apply: key.NewBinding(key.WithKeys("r")),
Fix: key.NewBinding(key.WithKeys("f")),
Enter: key.NewBinding(key.WithKeys("enter")),
Back: key.NewBinding(key.WithKeys("esc")),
Quit: key.NewBinding(key.WithKeys("q")),
Tab: key.NewBinding(key.WithKeys("tab")),
ShiftTab: key.NewBinding(key.WithKeys("shift+tab")),
Delete: key.NewBinding(key.WithKeys("d")),
Sync: key.NewBinding(key.WithKeys("s")),
Push: key.NewBinding(key.WithKeys("S")),
Toggle: key.NewBinding(key.WithKeys("t")),
Logs: key.NewBinding(key.WithKeys("o")),
}
+51 -5
View File
@@ -1,8 +1,12 @@
package ui
import "ansibletui/internal/history"
import (
"ansibletui/internal/ansible"
"ansibletui/internal/compliance"
"ansibletui/internal/history"
)
// pingResultMsg is delivered when an ansible ping completes.
// pingResultMsg is delivered when a TCP probe completes.
type pingResultMsg struct {
host string
reachable bool
@@ -16,12 +20,54 @@ type outputLineMsg struct {
// runDoneMsg is delivered when a playbook run finishes.
type runDoneMsg struct {
record *history.RunRecord
log []byte
err error
record *history.RunRecord
log []byte
findings []ansible.Finding
err error
}
type complianceJobStartedMsg struct {
job compliance.Job
index int
total int
}
// complianceJobDoneMsg is sent when one parallel compliance job finishes.
// It carries the per-job outcome so the UI can display a breakdown.
type complianceJobDoneMsg struct {
job compliance.Job
index int
total int
status string // clean, drift, failed, unreachable
changed int
failed int
findings []ansible.Finding
}
type complianceDoneMsg struct {
record *history.RunRecord
log []byte
findings []ansible.Finding
summary compliance.Summary
action string
err error
}
// runsLoadedMsg carries a freshly loaded run history list.
type runsLoadedMsg struct {
runs []*history.RunRecord
}
// gitOpDoneMsg is delivered when a config-tab git sync or push finishes.
type gitOpDoneMsg struct {
repo string // "playbooks" or "inventory"
op string // "sync" or "push"
summary string
err error
}
// gitStatusMsg carries refreshed git status lines for the config tab.
type gitStatusMsg struct {
playbooks string
inventory string
}
+45
View File
@@ -2,6 +2,7 @@ package ui
import (
"fmt"
"sort"
"strings"
"github.com/charmbracelet/bubbles/key"
@@ -14,6 +15,7 @@ import (
func (a *App) openRunDetailsScreen(run *history.RunRecord) {
a.screen = ScreenRunDetails
a.viewingRun = run
a.viewingFindings, _ = a.hist.LoadFindings(run)
logBytes, _ := a.hist.LoadLog(run)
a.logVp.SetContent(string(logBytes))
@@ -26,6 +28,7 @@ func (a *App) updateRunDetails(msg tea.Msg) (tea.Model, tea.Cmd) {
case key.Matches(km, keys.Back), key.Matches(km, keys.Quit):
a.screen = ScreenHome
a.viewingRun = nil
a.viewingFindings = nil
return a, nil
}
}
@@ -48,8 +51,14 @@ func (a *App) viewRunDetails() string {
title := titleStyle.Render(fmt.Sprintf("ansibleTUI / Run Details — %s", run.Playbook))
statusStr := renderRunStatus(run.Status)
if run.Mode == "check" || run.Mode == "check+diff" {
statusStr = renderRunStatusForCheck(run.Status, run.Changed)
}
recap := fmt.Sprintf("ok=%d changed=%d failed=%d unreachable=%d skipped=%d",
run.OK, run.Changed, run.Failed, run.Unreachable, run.Skipped)
if run.Mode == "check" || run.Mode == "check+diff" {
recap += " (check mode: changed = would change, not applied)"
}
meta := strings.Join([]string{
subtleStyle.Render("Playbook: ") + boldStyle.Render(run.Playbook),
@@ -57,6 +66,7 @@ func (a *App) viewRunDetails() string {
subtleStyle.Render("Mode: ") + boldStyle.Render(run.Mode),
subtleStyle.Render("Status: ") + statusStr,
subtleStyle.Render("Recap: ") + dimStyle.Render(recap),
subtleStyle.Render("Summary: ") + dimStyle.Render(a.runFindingSummary()),
subtleStyle.Render("Started: ") + run.StartTime.Format("2006-01-02 15:04:05"),
}, "\n")
@@ -87,3 +97,38 @@ func (a *App) viewRunDetails() string {
footer,
)
}
func (a *App) runFindingSummary() string {
findings := a.viewingFindings
if len(findings) == 0 {
return "no structured findings"
}
drift := countStatus(findings, "changed")
failed := countStatus(findings, "failed")
unreach := countStatus(findings, "unreachable")
hosts := map[string]bool{}
tags := map[string]bool{}
for _, f := range findings {
if f.Status == "changed" || f.Status == "failed" || f.Status == "unreachable" {
hosts[f.Host] = true
}
for _, tag := range f.Tags {
tags[tag] = true
}
}
parts := []string{
fmt.Sprintf("drift=%d", drift),
fmt.Sprintf("failed=%d", failed),
fmt.Sprintf("unreachable=%d", unreach),
fmt.Sprintf("hosts=%d", len(hosts)),
}
if len(tags) > 0 {
var tagList []string
for tag := range tags {
tagList = append(tagList, tag)
}
sort.Strings(tagList)
parts = append(parts, "tags="+strings.Join(tagList, ","))
}
return strings.Join(parts, " ")
}
+52 -13
View File
@@ -1,6 +1,10 @@
package ui
import "github.com/charmbracelet/lipgloss"
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
// Palette
var (
@@ -12,7 +16,8 @@ var (
colorSubtle = lipgloss.Color("#aaaaaa")
colorWhite = lipgloss.Color("#f0f0f0")
colorSelected = lipgloss.Color("#1a4070")
colorBorder = lipgloss.Color("#5a5a5a")
colorBorder = lipgloss.Color("#34424d")
colorPanelBg = lipgloss.Color("#0b1117")
)
// Base text styles
@@ -24,19 +29,22 @@ var (
// Status badge styles
var (
statusOnline = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
statusFailed = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
statusClean = lipgloss.NewStyle().Foreground(colorGreen)
statusDrift = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
statusUnknown = lipgloss.NewStyle().Foreground(colorSubtle)
statusOnline = lipgloss.NewStyle().Foreground(colorGreen).Bold(true)
statusFailed = lipgloss.NewStyle().Foreground(colorRed).Bold(true)
statusClean = lipgloss.NewStyle().Foreground(colorGreen)
statusDrift = lipgloss.NewStyle().Foreground(colorYellow).Bold(true)
statusUnknown = lipgloss.NewStyle().Foreground(colorSubtle)
statusUnreachable = lipgloss.NewStyle().Foreground(colorRed)
statusScanning = lipgloss.NewStyle().Foreground(colorCyan)
statusFixing = lipgloss.NewStyle().Foreground(colorYellow).Italic(true)
)
// Panel / box styles
var (
panelStyle = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
Border(lipgloss.NormalBorder()).
BorderForeground(colorBorder).
Background(colorPanelBg).
Padding(0, 1)
panelHeaderStyle = lipgloss.NewStyle().
@@ -49,10 +57,11 @@ var (
var (
navActiveStyle = lipgloss.NewStyle().
Foreground(colorCyan).
Background(lipgloss.Color("#0d2040")).
Bold(true)
navStyle = lipgloss.NewStyle().
Foreground(colorSubtle)
Foreground(colorDim)
)
// Table
@@ -61,8 +70,12 @@ var (
Foreground(colorSubtle)
tableRowSelected = lipgloss.NewStyle().
Background(colorSelected).
Background(lipgloss.Color("#102b46")).
Foreground(colorWhite)
groupHeaderStyle = lipgloss.NewStyle().
Foreground(colorCyan).
Bold(true)
)
// Recent run list
@@ -94,9 +107,14 @@ var (
Padding(0, 1).Bold(true)
badgeFailedStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#3d0f0f")).
Foreground(lipgloss.Color("#ff5f57")).
Padding(0, 1).Bold(true)
Background(lipgloss.Color("#3d0f0f")).
Foreground(lipgloss.Color("#ff5f57")).
Padding(0, 1).Bold(true)
badgeUnknownStyle = lipgloss.NewStyle().
Background(lipgloss.Color("#262b32")).
Foreground(lipgloss.Color("#b6c2cf")).
Padding(0, 1).Bold(true)
)
// Title bar
@@ -147,6 +165,10 @@ func renderDrift(state string) string {
return statusDrift.Render("drift")
case "failed", "unreachable":
return statusUnreachable.Render(state)
case "scanning":
return statusScanning.Render("scanning")
case "fixing":
return statusFixing.Render("fixing")
default:
return statusUnknown.Render("unknown")
}
@@ -167,3 +189,20 @@ func renderRunStatus(status string) string {
return subtleStyle.Render(status)
}
}
// renderRunStatusForCheck labels check-mode outcomes (changed means would-change).
func renderRunStatusForCheck(status string, changed int) string {
switch status {
case "clean":
return runCleanStyle.Render("clean (no drift)")
case "drift":
if changed > 0 {
return runChangedStyle.Render(fmt.Sprintf("drift — would change %d task(s)", changed))
}
return runChangedStyle.Render("drift")
case "failed", "unreachable":
return renderRunStatus(status)
default:
return renderRunStatus(status)
}
}
+12 -5
View File
@@ -1,9 +1,9 @@
package main
import (
"context"
"fmt"
"os"
"path/filepath"
tea "github.com/charmbracelet/bubbletea"
@@ -20,19 +20,26 @@ func main() {
os.Exit(1)
}
inv, err := inventory.Load(cfg.InventoryPath)
if errs := cfg.SyncGitRepos(context.Background()); len(errs) > 0 {
for _, e := range errs {
fmt.Fprintf(os.Stderr, "git sync warning: %v\n", e)
}
}
invPath := cfg.EffectiveInventoryPath()
inv, err := inventory.Load(invPath)
if err != nil {
fmt.Fprintf(os.Stderr, "inventory: %v\n", err)
os.Exit(1)
}
appDir, err := config.AppDir()
runsDir, err := config.RunsDir()
if err != nil {
fmt.Fprintf(os.Stderr, "app dir: %v\n", err)
fmt.Fprintf(os.Stderr, "runs dir: %v\n", err)
os.Exit(1)
}
hist := history.New(filepath.Join(appDir, "runs"))
hist := history.New(runsDir)
app := ui.New(cfg, inv, hist)
p := tea.NewProgram(app, tea.WithAltScreen(), tea.WithMouseAllMotion())
+125
View File
@@ -0,0 +1,125 @@
//go:build integration
package integration
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"testing"
"time"
"ansibletui/internal/ansible"
"ansibletui/internal/runner"
)
// requireAnsible skips the test if ansible-playbook is not in PATH.
func requireAnsible(t *testing.T) {
t.Helper()
if _, err := exec.LookPath("ansible-playbook"); err != nil {
t.Skip("ansible-playbook not in PATH")
}
}
// livePlaybookDir returns ~/.ansibletui/playbooks, skipping if absent.
func livePlaybookDir(t *testing.T) string {
t.Helper()
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot determine home dir")
}
dir := filepath.Join(home, ".ansibletui", "playbooks")
if _, err := os.Stat(dir); err != nil {
t.Skipf("playbook dir not found at %s", dir)
}
return dir
}
// TestXcpGuestToolsCheckModeNoFailed is a regression test for the bug where
// ansible.builtin.apt tried to open a .deb file that was never downloaded
// (get_url only simulates the download in check mode), causing fatal: for
// every host that needed the package.
//
// Fix: added `when: not ansible_check_mode` to the Install and Remove tasks.
// After the fix, check mode must produce failed=0 for all hosts.
func TestXcpGuestToolsCheckModeNoFailed(t *testing.T) {
requireAnsible(t)
invPath := liveInventoryPath(t)
pbDir := livePlaybookDir(t)
playbookRel := filepath.Join("playbooks", "xcp-guest-tools.yml")
if _, err := os.Stat(filepath.Join(pbDir, playbookRel)); err != nil {
t.Skipf("xcp-guest-tools playbook not found: %v", err)
}
argv := ansible.BuildPlaybookArgs(invPath, playbookRel, "", ansible.ModeCheckDiff)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
lineCh := make(chan string, 256)
go func() {
for range lineCh { // drain
}
}()
_, log, err := runner.Run(ctx, argv, pbDir, lineCh)
// err from runner.Run is an *exec.ExitError when exit code != 0; non-nil
// is expected if any host is unreachable. We specifically check failed=.
if err != nil {
t.Logf("runner.Run returned error (may be unreachable hosts): %v", err)
}
recaps := ansible.ParseRecap(string(log))
if len(recaps) == 0 {
t.Fatal("no PLAY RECAP found in output — playbook may not have run at all")
}
var failures []string
for _, r := range recaps {
if r.Failed > 0 {
failures = append(failures, fmt.Sprintf("%s: failed=%d", r.Host, r.Failed))
}
}
if len(failures) > 0 {
t.Errorf("xcp-guest-tools check mode produced failures (regression):\n%v\n\nThis means the Install or Remove task ran in check mode when the downloaded .deb did not exist.", failures)
}
}
// TestSiteYmlCheckModeParseable verifies that site.yml --check --diff
// produces parseable PLAY RECAP output and that InterpretStatus can
// classify every host without crashing.
func TestSiteYmlCheckModeParseable(t *testing.T) {
requireAnsible(t)
invPath := liveInventoryPath(t)
pbDir := livePlaybookDir(t)
if _, err := os.Stat(filepath.Join(pbDir, "site.yml")); err != nil {
t.Skip("site.yml not found")
}
argv := ansible.BuildPlaybookArgs(invPath, "site.yml", "", ansible.ModeCheckDiff)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
lineCh := make(chan string, 512)
go func() {
for range lineCh {
}
}()
_, log, _ := runner.Run(ctx, argv, pbDir, lineCh)
recaps := ansible.ParseRecap(string(log))
if len(recaps) == 0 {
t.Fatal("no PLAY RECAP found — site.yml may have failed to start")
}
t.Logf("site.yml check+diff recap (%d hosts):", len(recaps))
for _, r := range recaps {
status := r.InterpretStatus(ansible.ModeCheckDiff)
t.Logf(" %-20s ok=%-3d changed=%-3d failed=%-3d unreachable=%-3d → %s",
r.Host, r.OK, r.Changed, r.Failed, r.Unreachable, status)
}
}
+88
View File
@@ -0,0 +1,88 @@
//go:build integration
package integration
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
"time"
"ansibletui/internal/inventory"
"ansibletui/internal/runner"
)
// TestFleetReachability pings every host in the live inventory and reports
// which are unreachable. It fails if any individual host is unreachable so
// CI catches regressions. If ALL hosts are unreachable the test is skipped
// instead — that indicates a network / infrastructure outage rather than a
// code bug.
func TestFleetReachability(t *testing.T) {
invPath := liveInventoryPath(t)
inv, err := inventory.Load(invPath)
if err != nil {
t.Fatalf("load inventory %s: %v", invPath, err)
}
if len(inv.Hosts) == 0 {
t.Skip("inventory is empty")
}
type result struct {
host string
reachable bool
err error
}
results := make([]result, len(inv.Hosts))
var wg sync.WaitGroup
for i, h := range inv.Hosts {
wg.Add(1)
go func(i int, host string) {
defer wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
ok, err := runner.Ping(ctx, invPath, host)
results[i] = result{host: host, reachable: ok, err: err}
}(i, h.Name)
}
wg.Wait()
var unreachable []result
for _, r := range results {
if !r.reachable {
unreachable = append(unreachable, r)
}
}
if len(unreachable) == len(inv.Hosts) {
// All hosts failed — likely a local network or SSH config issue.
// Skip rather than fail so CI doesn't permanently red.
msg := "all hosts unreachable — possible network or SSH config issue:\n"
for _, r := range unreachable {
msg += fmt.Sprintf(" %s: %v\n", r.host, r.err)
}
t.Skip(msg)
}
for _, r := range unreachable {
t.Errorf("host %s is unreachable: %v", r.host, r.err)
}
}
// liveInventoryPath returns the path to the real inventory file, skipping
// the test if it doesn't exist.
func liveInventoryPath(t *testing.T) string {
t.Helper()
home, err := os.UserHomeDir()
if err != nil {
t.Skip("cannot determine home dir:", err)
}
path := filepath.Join(home, ".ansibletui", "inventory.yml")
if _, err := os.Stat(path); err != nil {
t.Skipf("live inventory not found at %s", path)
}
return path
}