Files
Andrey KizimenkoandGeorge Karr 0f9af89a93 Add Hangar: Go/Wails desktop control panel for the Fleet dev environment (#46406)
## Summary

Adds `tools/hangar` — a macOS desktop control panel for working on Fleet
locally: branch management, `fleet serve` orchestration, log tail, dev
MySQL backup/restore, `fleetctl`, GitOps, and `osquery-perf`, all in one
window.

Built with **Go + [Wails 3](https://v3alpha.wails.io)** — the backend is
plain Go (`os/exec`, `syscall`, goroutines) so Fleet engineers can
contribute to it; only the desktop shell is Wails. The `internal/`
packages are pure and unit-tested.

### History note
Hangar started as a Rust/Tauri app. It was ported to Go, and **the Go
port is now the canonical `tools/hangar`**. The original Rust/Tauri
implementation has been removed from the monorepo (preserved in a
standalone repo) — so although this branch's earlier commits add and
then replace the Rust app, the net diff is just the Go app at
`tools/hangar`. The bundle identifier is `com.fleetdm.fleet-hangar`,
matching the original app so existing settings carry over.

# Checklist for submitter

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented (using placeholders for values in statements), JS
inline code is prevented especially for url redirects, and untrusted
data interpolated into shell scripts/commands is validated against shell
metacharacters.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops

> No `changes/` file: `tools/` is contributor tooling, not a
user-visible Fleet change. No DB migrations, no Fleet config settings,
no fleetd/orbit changes.

## Testing

- [x] Added/updated automated tests (Go unit tests across `internal/`,
including a path-traversal regression for backup deletion)
- [x] QA'd all new/changed functionality manually

## Test plan
- [x] `cd tools/hangar && task dev` launches the app (live-reload)
- [x] `task build` produces `bin/fleet-hangar`; `go test ./...` is green
- [x] First-run gate discovers a local Fleet clone and runs dep checks
- [x] Server tab can run the build chain and start `fleet serve`
- [x] Git tab branch search finds an older branch (e.g. a stale `qa-*`)
by name


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Introduced Fleet Hangar, a comprehensive desktop application for Fleet
development workflows, providing unified controls for server/database
management, git operations, configuration, logging, and troubleshooting.
  * Added database backup management with metadata tracking.
* Integrated process orchestration for development services (Docker,
ngrok, Python).
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: George Karr <georgekarrv@users.noreply.github.com>
2026-06-23 15:45:23 -05:00

136 lines
4.1 KiB
Go

package processes
import "testing"
func TestRingDropsOldest(t *testing.T) {
r := newRing(3)
for i := 1; i <= 5; i++ {
r.push(LogEntry{TsMS: uint64(i)})
}
snap := r.snapshot()
if len(snap) != 3 {
t.Fatalf("size = %d, want 3", len(snap))
}
// Oldest two (1,2) dropped; remaining 3,4,5 oldest→newest.
for i, want := range []uint64{3, 4, 5} {
if snap[i].TsMS != want {
t.Errorf("snap[%d].TsMS = %d, want %d", i, snap[i].TsMS, want)
}
}
}
func lvl(s string) *string { return &s }
func sampleChannels() map[string][]LogEntry {
return map[string][]LogEntry{
"fleet-serve": {
{TsMS: 10, Level: lvl("info"), Message: "starting up", Channel: "fleet-serve"},
{TsMS: 20, Level: lvl("warn"), Message: "disk getting full", Channel: "fleet-serve"},
{TsMS: 30, Level: lvl("error"), Message: "connection refused", Channel: "fleet-serve"},
{TsMS: 40, Level: nil, Message: "no level here", Channel: "fleet-serve"}, // treated as info
},
"docker-compose": {
{TsMS: 15, Level: lvl("info"), Message: "container up", Channel: "docker-compose"},
},
}
}
func TestFilterLogWindowLevels(t *testing.T) {
ch := sampleChannels()
all := []string{"debug", "info", "warn", "error"}
// All levels, source all.
w := filterLogWindow(ch, "all", 0, all, nil, nil)
if w.TotalInWindow != 5 {
t.Errorf("total = %d, want 5", w.TotalInWindow)
}
if w.WarnCount != 1 || w.ErrorCount != 1 {
t.Errorf("warn=%d error=%d, want 1/1", w.WarnCount, w.ErrorCount)
}
// Sorted ascending by ts across channels (10,15,20,30,40).
wantTs := []uint64{10, 15, 20, 30, 40}
for i, ts := range wantTs {
if w.Entries[i].TsMS != ts {
t.Errorf("entry[%d].ts = %d, want %d", i, w.Entries[i].TsMS, ts)
}
}
// Only errors.
w = filterLogWindow(ch, "all", 0, []string{"error"}, nil, nil)
if w.TotalInWindow != 1 || w.Entries[0].Message != "connection refused" {
t.Errorf("error-only filter wrong: %+v", w)
}
// Empty level set → nothing.
w = filterLogWindow(ch, "all", 0, nil, nil, nil)
if w.TotalInWindow != 0 || len(w.Entries) != 0 {
t.Errorf("empty levels should yield nothing, got %+v", w)
}
}
func TestFilterLogWindowSource(t *testing.T) {
ch := sampleChannels()
all := []string{"debug", "info", "warn", "error"}
w := filterLogWindow(ch, "docker-compose", 0, all, nil, nil)
if w.TotalInWindow != 1 || w.Entries[0].Message != "container up" {
t.Errorf("source filter wrong: %+v", w)
}
// Unknown source → no channels selected.
w = filterLogWindow(ch, "nope", 0, all, nil, nil)
if w.TotalInWindow != 0 {
t.Errorf("unknown source should be empty, got %d", w.TotalInWindow)
}
}
func TestFilterLogWindowSince(t *testing.T) {
ch := sampleChannels()
all := []string{"debug", "info", "warn", "error"}
w := filterLogWindow(ch, "all", 25, all, nil, nil)
// Only ts >= 25: the error(30) and the nil-level(40).
if w.TotalInWindow != 2 {
t.Errorf("since filter total = %d, want 2", w.TotalInWindow)
}
}
func TestFilterLogWindowSearch(t *testing.T) {
ch := sampleChannels()
all := []string{"debug", "info", "warn", "error"}
// Substring (case-insensitive).
s := "REFUSED"
w := filterLogWindow(ch, "all", 0, all, &s, nil)
if w.TotalInWindow != 1 || w.Entries[0].Message != "connection refused" {
t.Errorf("substring search wrong: %+v", w)
}
// Regex.
rx := "/full|refused/"
w = filterLogWindow(ch, "all", 0, all, &rx, nil)
if w.TotalInWindow != 2 {
t.Errorf("regex search total = %d, want 2", w.TotalInWindow)
}
// Invalid regex → filtering disabled (everything passes).
bad := "/[invalid/"
w = filterLogWindow(ch, "all", 0, all, &bad, nil)
if w.TotalInWindow != 5 {
t.Errorf("invalid regex should disable search, got total %d", w.TotalInWindow)
}
}
func TestFilterLogWindowMaxLines(t *testing.T) {
ch := sampleChannels()
all := []string{"debug", "info", "warn", "error"}
max := 2
w := filterLogWindow(ch, "all", 0, all, nil, &max)
// total counts all matches (pre-cap); entries capped to newest 2 (ts 30,40).
if w.TotalInWindow != 5 {
t.Errorf("total should be pre-cap 5, got %d", w.TotalInWindow)
}
if len(w.Entries) != 2 || w.Entries[0].TsMS != 30 || w.Entries[1].TsMS != 40 {
t.Errorf("maxLines should keep newest 2, got %+v", w.Entries)
}
}