## 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>
245 lines
6.5 KiB
Go
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
|
|
}
|