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

226 lines
6.7 KiB
Go

package shellpath
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestLastNonEmptyLine(t *testing.T) {
cases := []struct {
in, want string
}{
// Banner line before the PATH (no trailing newline after PATH).
{"Restored session: Thu May 28\n/opt/homebrew/bin:/usr/bin", "/opt/homebrew/bin:/usr/bin"},
{"/usr/bin:/bin", "/usr/bin:/bin"},
{" /usr/bin:/bin ", "/usr/bin:/bin"}, // trimmed
{"line1\n\nline2\n\n", "line2"},
{"", ""},
{"\n\n", ""},
}
for _, c := range cases {
if got := lastNonEmptyLine(c.in); got != c.want {
t.Errorf("lastNonEmptyLine(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestAugmentInherited(t *testing.T) {
home := "/Users/tester"
got := augmentInherited(home, "/usr/bin:/bin")
parts := strings.Split(got, ":")
// Homebrew bin must come first (prepended dirs win over inherited).
if parts[0] != "/opt/homebrew/bin" {
t.Errorf("first entry = %q, want /opt/homebrew/bin", parts[0])
}
// Home-derived tool dirs are present.
for _, want := range []string{"/Users/tester/go/bin", "/Users/tester/.local/bin", "/Users/tester/.fleetctl"} {
if !strings.Contains(got, want) {
t.Errorf("augmented PATH missing %q: %s", want, got)
}
}
// Inherited entries are appended.
if !strings.Contains(got, "/usr/bin") || !strings.Contains(got, "/bin") {
t.Errorf("inherited entries dropped: %s", got)
}
}
func TestAugmentInheritedDedup(t *testing.T) {
// An inherited entry that duplicates a prepended one must not appear twice.
got := augmentInherited("/Users/tester", "/opt/homebrew/bin:/usr/bin:/usr/bin")
if n := strings.Count(got, "/opt/homebrew/bin"); n != 1 {
t.Errorf("/opt/homebrew/bin appears %d times, want 1: %s", n, got)
}
if n := strings.Count(got, ":/usr/bin"); n != 1 {
t.Errorf("/usr/bin appears %d times, want 1: %s", n, got)
}
}
func TestMergeEnv(t *testing.T) {
base := []string{"PATH=/bin", "HOME=/h", "FOO=old"}
got := MergeEnv(base, map[string]string{"FOO": "new", "BAR": "added"})
m := map[string]string{}
for _, e := range got {
if i := indexByte(e, '='); i >= 0 {
m[e[:i]] = e[i+1:]
}
}
if m["FOO"] != "new" {
t.Errorf("FOO = %q, want new (replaced in place)", m["FOO"])
}
if m["BAR"] != "added" {
t.Errorf("BAR = %q, want added", m["BAR"])
}
if m["PATH"] != "/bin" || m["HOME"] != "/h" {
t.Errorf("untouched vars changed: %v", m)
}
// FOO must not be duplicated.
count := 0
for _, e := range got {
if len(e) >= 4 && e[:4] == "FOO=" {
count++
}
}
if count != 1 {
t.Errorf("FOO appears %d times, want 1", count)
}
// Empty extra returns base unchanged.
if out := MergeEnv(base, nil); len(out) != len(base) {
t.Errorf("MergeEnv with nil extra changed length: %v", out)
}
}
func indexByte(s string, b byte) int {
for i := 0; i < len(s); i++ {
if s[i] == b {
return i
}
}
return -1
}
func TestEnvWith(t *testing.T) {
// Overrides an existing PATH in place, keeps other vars.
base := []string{"HOME=/Users/tester", "PATH=/old/bin", "FOO=bar"}
got := envWith(base, "/new/bin")
var pathCount int
var sawHome, sawFoo bool
for _, e := range got {
switch {
case e == "PATH=/new/bin":
pathCount++
case e == "PATH=/old/bin":
t.Error("old PATH should be replaced")
case e == "HOME=/Users/tester":
sawHome = true
case e == "FOO=bar":
sawFoo = true
}
}
if pathCount != 1 {
t.Errorf("want exactly one PATH entry, got %d in %v", pathCount, got)
}
if !sawHome || !sawFoo {
t.Errorf("non-PATH vars dropped: %v", got)
}
// Appends PATH when the base has none.
got = envWith([]string{"HOME=/x"}, "/new/bin")
found := false
for _, e := range got {
if e == "PATH=/new/bin" {
found = true
}
}
if !found {
t.Errorf("PATH not appended: %v", got)
}
}
func TestAugmentInheritedEmptyHome(t *testing.T) {
// No home dir: skip the ~-derived entries (don't emit relative-path
// artifacts like "go/bin" from joining onto ""), still return the
// fixed dirs. Note /usr/local/go/bin is a FIXED dir and stays.
got := augmentInherited("", "/usr/bin")
for _, p := range strings.Split(got, ":") {
if p == "go/bin" || p == ".local/bin" || p == ".fleetctl" {
t.Errorf("empty home produced relative entry %q: %s", p, got)
}
}
if !strings.HasPrefix(got, "/opt/homebrew/bin") {
t.Errorf("want homebrew prefix, got %s", got)
}
}
func TestLookPathIn(t *testing.T) {
// Build a fake PATH: an empty dir, then one holding an executable "tool"
// and a non-executable "data" file and a "sub" directory.
dirEmpty := t.TempDir()
dirReal := t.TempDir()
if err := os.WriteFile(filepath.Join(dirReal, "tool"), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dirReal, "data"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dirReal, "sub"), 0o755); err != nil {
t.Fatal(err)
}
path := dirEmpty + ":" + dirReal
// Resolves an executable to the first PATH dir that has it.
if got, err := LookPathIn(path, "tool"); err != nil || got != filepath.Join(dirReal, "tool") {
t.Errorf("LookPathIn(tool) = %q, %v; want %q", got, err, filepath.Join(dirReal, "tool"))
}
// A non-executable file is not a match.
if _, err := LookPathIn(path, "data"); err == nil {
t.Error("LookPathIn(data): want error for non-executable file")
}
// A directory is not a match.
if _, err := LookPathIn(path, "sub"); err == nil {
t.Error("LookPathIn(sub): want error for directory")
}
// A name not present anywhere errors.
if _, err := LookPathIn(path, "absent"); err == nil {
t.Error("LookPathIn(absent): want not-found error")
}
// A name containing a separator is checked directly, not searched.
abs := filepath.Join(dirReal, "tool")
if got, err := LookPathIn(dirEmpty, abs); err != nil || got != abs {
t.Errorf("LookPathIn(abs path) = %q, %v; want %q", got, err, abs)
}
}
func TestCommandResolvesAndSetsEnv(t *testing.T) {
// Pin the cached shell PATH to a temp dir holding "tool" so Command
// resolves to an absolute path (not the bare name) and presets cmd.Env.
dir := t.TempDir()
toolPath := filepath.Join(dir, "tool")
if err := os.WriteFile(toolPath, []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
mu.Lock()
orig := cached
cached = dir
mu.Unlock()
t.Cleanup(func() { mu.Lock(); cached = orig; mu.Unlock() })
cmd := Command("tool", "--flag")
if cmd.Path != toolPath {
t.Errorf("Command resolved Path = %q, want %q", cmd.Path, toolPath)
}
if len(cmd.Env) == 0 {
t.Error("Command should preset cmd.Env to the login-shell env")
}
// A name not on the shell PATH falls back to the bare name so Start()
// still produces the standard not-found error.
if miss := Command("definitely-not-a-real-tool"); miss.Path == "" {
t.Error("Command(miss) should keep the bare name in Path")
}
}