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

141 lines
3.9 KiB
Go

package gitops
import (
"os"
"path/filepath"
"testing"
)
func mkdir(t *testing.T, p string) {
t.Helper()
if err := os.MkdirAll(p, 0o755); err != nil {
t.Fatal(err)
}
}
func touch(t *testing.T, p string) {
t.Helper()
mkdir(t, filepath.Dir(p))
if err := os.WriteFile(p, []byte("x: 1\n"), 0o644); err != nil {
t.Fatal(err)
}
}
func TestListReposSingleMode(t *testing.T) {
root := t.TempDir()
touch(t, filepath.Join(root, "default.yml"))
touch(t, filepath.Join(root, "teams", "alpha.yml"))
scan, err := ListRepos(root)
if err != nil {
t.Fatal(err)
}
if !scan.SingleRepoMode {
t.Error("root with default.yml should be single-repo mode")
}
if len(scan.Repos) != 1 {
t.Fatalf("want 1 repo, got %d", len(scan.Repos))
}
if len(scan.Repos[0].TeamFiles) != 1 || scan.Repos[0].TeamFiles[0].Name != "alpha.yml" {
t.Errorf("team files = %+v", scan.Repos[0].TeamFiles)
}
}
func TestListReposMultiMode(t *testing.T) {
root := t.TempDir()
// Two valid repos + one ignored (no default.yml).
touch(t, filepath.Join(root, "repo-b", "default.yml"))
touch(t, filepath.Join(root, "repo-a", "default.yml"))
touch(t, filepath.Join(root, "repo-a", "teams", "t2.yml"))
touch(t, filepath.Join(root, "repo-a", "teams", "t1.yml"))
touch(t, filepath.Join(root, "repo-a", "fleets", "f1.yml"))
touch(t, filepath.Join(root, "repo-a", "teams", ".hidden.yml")) // skipped
touch(t, filepath.Join(root, "repo-a", "teams", "notes.txt")) // skipped
mkdir(t, filepath.Join(root, "not-a-repo"))
scan, err := ListRepos(root)
if err != nil {
t.Fatal(err)
}
if scan.SingleRepoMode {
t.Error("multi-repo dir should not be single-repo mode")
}
if len(scan.Repos) != 2 {
t.Fatalf("want 2 repos, got %d: %+v", len(scan.Repos), scan.Repos)
}
// Sorted by name.
if scan.Repos[0].Name != "repo-a" || scan.Repos[1].Name != "repo-b" {
t.Errorf("repos not sorted: %s, %s", scan.Repos[0].Name, scan.Repos[1].Name)
}
// teams before fleets, each group sorted, hidden + non-yaml skipped.
tf := scan.Repos[0].TeamFiles
wantOrder := []string{"t1.yml", "t2.yml", "f1.yml"}
if len(tf) != 3 {
t.Fatalf("team files = %+v, want 3", tf)
}
for i, name := range wantOrder {
if tf[i].Name != name {
t.Errorf("team file[%d] = %q, want %q", i, tf[i].Name, name)
}
}
if tf[0].Subdir != "teams" || tf[2].Subdir != "fleets" {
t.Errorf("subdir tags wrong: %+v", tf)
}
// "not-a-repo" surfaced as ignored.
if len(scan.Ignored) != 1 || scan.Ignored[0] != "not-a-repo" {
t.Errorf("ignored = %v, want [not-a-repo]", scan.Ignored)
}
}
func TestListReposNotADir(t *testing.T) {
if _, err := ListRepos(filepath.Join(t.TempDir(), "missing")); err == nil {
t.Error("missing dir should error")
}
}
func TestCheckTarget(t *testing.T) {
root := t.TempDir()
// Invalid names.
for _, bad := range []string{"", "a/b", "..", ".", "~", `a\b`} {
got, _ := CheckTarget(root, bad)
if got.Reason == nil {
t.Errorf("CheckTarget name %q should have a reason", bad)
}
}
// Parent doesn't exist.
got, _ := CheckTarget(filepath.Join(root, "nope"), "team")
if got.Reason == nil {
t.Error("missing parent should have a reason")
}
// Target available (doesn't exist yet).
got, _ = CheckTarget(root, "fresh")
if got.Exists || got.Reason != nil {
t.Errorf("fresh target should be available: %+v", got)
}
if !got.Writable {
t.Error("temp dir parent should be writable")
}
// Target is an existing file.
touch(t, filepath.Join(root, "afile"))
got, _ = CheckTarget(root, "afile")
if !got.Exists || got.Reason == nil {
t.Errorf("file target should report a reason: %+v", got)
}
// Target is an existing dir with files.
mkdir(t, filepath.Join(root, "existing"))
touch(t, filepath.Join(root, "existing", "a.yml"))
touch(t, filepath.Join(root, "existing", "b.yml"))
got, _ = CheckTarget(root, "existing")
if !got.Exists || got.Reason != nil {
t.Errorf("existing dir should be ok: %+v", got)
}
if got.FileCount != 2 {
t.Errorf("file count = %d, want 2", got.FileCount)
}
}