## 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>
141 lines
3.9 KiB
Go
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)
|
|
}
|
|
}
|