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:
@@ -3,6 +3,11 @@
|
||||
*.exe~
|
||||
ansibletui
|
||||
ansibletui-linux
|
||||
annsibletui-linux
|
||||
|
||||
# Local tooling
|
||||
.claude/
|
||||
tools.yaml
|
||||
|
||||
# Test binary, built with `go test -c`
|
||||
*.test
|
||||
|
||||
@@ -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 ./...
|
||||
@@ -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).
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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] {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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") + " " +
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, " ")
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user