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

245 lines
6.5 KiB
Go

// Package gitops discovers GitOps repos under a configured directory and
// validates generate targets. Ported from src-tauri/src/gitops.rs. A
// "repo" is any directory containing default.yml.
package gitops
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"github.com/fleetdm/fleet/tools/hangar/internal/paths"
)
// File is a team/fleet YAML found inside a repo.
type File struct {
Name string `json:"name"`
Path string `json:"path"`
Size uint64 `json:"size"`
MtimeMS uint64 `json:"mtime_ms"`
Subdir string `json:"subdir"` // "teams" or "fleets"
}
// Repo is a discovered GitOps repo (one that has default.yml).
type Repo struct {
Name string `json:"name"`
Path string `json:"path"`
HasDefault bool `json:"has_default"`
DefaultPath string `json:"default_path"`
DefaultSize uint64 `json:"default_size"`
DefaultMtimeMS uint64 `json:"default_mtime_ms"`
TeamFiles []File `json:"team_files"`
}
// DirScan is the result of scanning the configured GitOps directory.
type DirScan struct {
Root string `json:"root"`
SingleRepoMode bool `json:"single_repo_mode"`
Repos []Repo `json:"repos"`
Ignored []string `json:"ignored"`
}
// TargetCheck validates a generate target subdirectory.
type TargetCheck struct {
Path string `json:"path"`
Exists bool `json:"exists"`
FileCount uint32 `json:"file_count"`
Writable bool `json:"writable"`
Reason *string `json:"reason"`
}
func mtimeMS(p string) uint64 {
fi, err := os.Stat(p)
if err != nil {
return 0
}
return uint64(fi.ModTime().UnixMilli())
}
func fileSize(p string) uint64 {
fi, err := os.Stat(p)
if err != nil {
return 0
}
return uint64(fi.Size())
}
// collectTeamYAMLs returns all non-hidden *.yml/*.yaml in <repo>/<subdir>,
// sorted by name. Empty when the subdir is absent.
func collectTeamYAMLs(repo, subdir string) []File {
dir := filepath.Join(repo, subdir)
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []File
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, ".") {
continue
}
lower := strings.ToLower(name)
if !strings.HasSuffix(lower, ".yml") && !strings.HasSuffix(lower, ".yaml") {
continue
}
p := filepath.Join(dir, name)
out = append(out, File{
Name: name,
Path: p,
Size: fileSize(p),
MtimeMS: mtimeMS(p),
Subdir: subdir,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// buildRepo builds a Repo if repoPath contains default.yml, else nil.
// team_files lists teams first, then fleets (older convention reads first).
func buildRepo(repoPath string) *Repo {
defaultYML := filepath.Join(repoPath, "default.yml")
if fi, err := os.Stat(defaultYML); err != nil || !fi.Mode().IsRegular() {
return nil
}
teamFiles := collectTeamYAMLs(repoPath, "teams")
teamFiles = append(teamFiles, collectTeamYAMLs(repoPath, "fleets")...)
return &Repo{
Name: filepath.Base(repoPath),
Path: repoPath,
HasDefault: true,
DefaultPath: defaultYML,
DefaultSize: fileSize(defaultYML),
DefaultMtimeMS: mtimeMS(defaultYML),
TeamFiles: teamFiles,
}
}
// ListRepos scans dir. If dir itself contains default.yml it's treated as a
// single repo; otherwise its direct child dirs are scanned, with those
// lacking default.yml surfaced in Ignored.
func ListRepos(dir string) (DirScan, error) {
root := paths.Expand(dir)
fi, err := os.Stat(root)
if err != nil || !fi.IsDir() {
return DirScan{}, fmt.Errorf("not a directory: %s", root)
}
// Single-repo mode: the configured dir IS the repo.
if r := buildRepo(root); r != nil {
return DirScan{
Root: root,
SingleRepoMode: true,
Repos: []Repo{*r},
Ignored: []string{},
}, nil
}
entries, err := os.ReadDir(root)
if err != nil {
return DirScan{}, err
}
repos := []Repo{}
ignored := []string{}
for _, e := range entries {
if !e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
p := filepath.Join(root, e.Name())
if r := buildRepo(p); r != nil {
repos = append(repos, *r)
} else {
ignored = append(ignored, e.Name())
}
}
sort.Slice(repos, func(i, j int) bool { return repos[i].Name < repos[j].Name })
sort.Strings(ignored)
return DirScan{Root: root, SingleRepoMode: false, Repos: repos, Ignored: ignored}, nil
}
// countFiles counts regular files under path, capped at cap (so a huge tree
// doesn't stall — the UI only needs "N" vs "cap+").
func countFiles(path string, cap uint32) uint32 {
var total uint32
stack := []string{path}
for len(stack) > 0 {
if total >= cap {
return cap
}
p := stack[len(stack)-1]
stack = stack[:len(stack)-1]
entries, err := os.ReadDir(p)
if err != nil {
continue
}
for _, e := range entries {
if total >= cap {
return cap
}
ep := filepath.Join(p, e.Name())
if e.IsDir() {
stack = append(stack, ep)
} else if e.Type().IsRegular() {
total++
}
}
}
return total
}
// writable mirrors the Rust heuristic (!permissions.readonly()): any write
// bit set. Not an ownership-aware check — just a fast hint for the UI.
func writable(p string) bool {
fi, err := os.Stat(p)
if err != nil {
return false
}
return fi.Mode().Perm()&0o222 != 0
}
// CheckTarget validates the subdirectory name the user wants to generate
// into. It creates nothing — just answers "is this a safe spot".
func CheckTarget(dir, name string) (TargetCheck, error) {
root := paths.Expand(dir)
reason := func(s string) *string { return &s }
if name == "" || strings.ContainsAny(name, `/\`) || strings.Contains(name, "..") || name == "." || name == "~" {
return TargetCheck{
Path: filepath.Join(root, name),
Reason: reason("invalid subdirectory name"),
}, nil
}
if fi, err := os.Stat(root); err != nil || !fi.IsDir() {
return TargetCheck{
Path: filepath.Join(root, name),
Reason: reason(fmt.Sprintf("parent directory does not exist: %s", root)),
}, nil
}
target := filepath.Join(root, name)
fi, err := os.Stat(target)
if err != nil {
// Parent exists, target doesn't — the "available" state. Check the
// parent for writability.
return TargetCheck{Path: target, Exists: false, Writable: writable(root)}, nil
}
if fi.Mode().IsRegular() {
return TargetCheck{
Path: target,
Exists: true,
Reason: reason("target is a file, not a directory"),
}, nil
}
return TargetCheck{
Path: target,
Exists: true,
FileCount: countFiles(target, 200),
Writable: writable(target),
}, nil
}