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

253 lines
7.5 KiB
Go

// Package shellpath resolves the PATH to hand to spawned child processes.
//
// A macOS app launched from Finder/Dock inherits only the bare
// /usr/bin:/bin:/usr/sbin:/sbin — NOT the user's shell PATH. That omits
// Homebrew (/opt/homebrew/bin), Go (/usr/local/go/bin), nvm, ~/.fleetctl,
// etc., so bare-name spawns the app relies on (git, go, docker, ngrok,
// python3, ...) fail with "No such file or directory" in the packaged app,
// even though they work under `wails3 dev` (which inherits the terminal's
// PATH).
//
// We capture the user's real login-shell PATH once at startup and apply it
// to every child spawn. Ported from src-tauri/src/shellpath.rs.
package shellpath
import (
"context"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
)
var (
mu sync.Mutex
cached string
)
// ShellPath returns the PATH string to set on spawned children. Computed
// lazily on the first call and cached; later calls return the cached value.
func ShellPath() string {
mu.Lock()
defer mu.Unlock()
if cached == "" {
cached = resolve()
}
return cached
}
// Warm eagerly populates the cache (e.g. at startup) so the first real
// spawn doesn't pay the shell-probe latency. Safe to call repeatedly.
func Warm() { ShellPath() }
// Command builds an *exec.Cmd for an external tool, resolving the program
// against the login-shell PATH and presetting the child's env to the
// login-shell environment. Callers may set Dir/Stdin/etc. and layer extra
// env onto cmd.Env with MergeEnv.
//
// This is the crux of spawning tools from a Finder-launched app. Go's
// exec.Command resolves a bare program name ("docker", "ngrok", ...) against
// the PARENT process's $PATH at construction time, and IGNORES the PATH set
// on the child's cmd.Env. A packaged app inherits only the bare
// /usr/bin:/bin:/usr/sbin:/sbin from Finder/Dock, so we resolve the absolute
// path here and hand THAT to exec.Command. (Rust's std::process::Command
// resolves against the PATH set via .env(), so the port couldn't translate
// that line literally.)
func Command(name string, args ...string) *exec.Cmd {
cmd := exec.Command(resolveOrName(name), args...)
cmd.Env = Env()
return cmd
}
// CommandContext is Command with a context for cancellation/timeout.
func CommandContext(ctx context.Context, name string, args ...string) *exec.Cmd {
cmd := exec.CommandContext(ctx, resolveOrName(name), args...)
cmd.Env = Env()
return cmd
}
// LookPath resolves an executable name against the login-shell PATH, instead
// of the app's own (possibly bare) process PATH that the stdlib's
// exec.LookPath consults. A name that already contains a separator is only
// checked for runnability, mirroring exec.LookPath.
func LookPath(file string) (string, error) {
return LookPathIn(ShellPath(), file)
}
// LookPathIn is LookPath against an explicit PATH string — for callers that
// already hold a resolved PATH (e.g. the dep-version probe).
func LookPathIn(path, file string) (string, error) {
if strings.ContainsRune(file, os.PathSeparator) {
if err := isExecutableFile(file); err != nil {
return "", &exec.Error{Name: file, Err: err}
}
return file, nil
}
for _, dir := range filepath.SplitList(path) {
if dir == "" {
dir = "." // a bare colon in PATH means the current directory
}
full := filepath.Join(dir, file)
if isExecutableFile(full) == nil {
return full, nil
}
}
return "", &exec.Error{Name: file, Err: exec.ErrNotFound}
}
// resolveOrName returns the shell-PATH-resolved absolute path, or the
// original bare name on miss so exec.Command/Start still yields the familiar
// "executable file not found" error.
func resolveOrName(name string) string {
if p, err := LookPath(name); err == nil {
return p
}
return name
}
func isExecutableFile(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if info.IsDir() || info.Mode()&0o111 == 0 {
return os.ErrPermission
}
return nil
}
// Refresh re-probes and overwrites the cache, returning the new value.
// The dep-check screen calls this on "Recheck" so tools the user just
// installed (which modified .zprofile) become visible to every subsequent
// spawn, not just the next Recheck.
func Refresh() string {
p := resolve()
mu.Lock()
cached = p
mu.Unlock()
slog.Debug("re-probed login-shell PATH", "entries", strings.Count(p, ":")+1)
return p
}
// Env returns the current process environment with PATH overridden to the
// resolved login-shell PATH — suitable for exec.Cmd.Env so spawned tools
// (git, fleetctl, docker, ...) resolve when launched from Finder.
func Env() []string {
return envWith(os.Environ(), ShellPath())
}
// MergeEnv layers extra KEY=VALUE pairs over an environment slice, replacing
// existing keys in place (so the child's getenv sees the override, not a
// duplicate) and appending new ones. Used by the spawn paths to apply
// caller-supplied env on top of the inherited+PATH-overridden environment.
func MergeEnv(base []string, extra map[string]string) []string {
if len(extra) == 0 {
return base
}
out := append([]string(nil), base...)
idx := map[string]int{}
for i, e := range out {
if eq := strings.IndexByte(e, '='); eq >= 0 {
idx[e[:eq]] = i
}
}
for k, v := range extra {
if i, ok := idx[k]; ok {
out[i] = k + "=" + v
} else {
idx[k] = len(out)
out = append(out, k+"="+v)
}
}
return out
}
func envWith(base []string, path string) []string {
out := make([]string, 0, len(base)+1)
replaced := false
for _, e := range base {
if strings.HasPrefix(e, "PATH=") {
out = append(out, "PATH="+path)
replaced = true
} else {
out = append(out, e)
}
}
if !replaced {
out = append(out, "PATH="+path)
}
return out
}
func resolve() string {
if p := probeLoginShell(); p != "" {
return p
}
home, _ := os.UserHomeDir()
return augmentInherited(home, os.Getenv("PATH"))
}
// probeLoginShell asks the user's login shell for its PATH. "-ilc" so the
// rc/profile files that actually set PATH (Homebrew shellenv, nvm, custom
// exports) are sourced. Returns "" on any failure.
func probeLoginShell() string {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/zsh"
}
out, err := exec.Command(shell, "-ilc", `printf %s "$PATH"`).Output()
if err != nil {
return ""
}
p := lastNonEmptyLine(string(out))
// Sanity-check: a real PATH has separators and absolute entries.
if !strings.Contains(p, "/") {
return ""
}
return p
}
// lastNonEmptyLine returns the last non-empty, trimmed line of s. Some
// interactive shells (zsh with session restoration) print a banner like
// "Restored session: ..." before our command runs; that banner always
// precedes our printf, so the PATH is the last line.
func lastNonEmptyLine(s string) string {
var last string
for _, line := range strings.Split(s, "\n") {
if t := strings.TrimSpace(line); t != "" {
last = t
}
}
return last
}
// augmentInherited is the fallback when the shell probe fails: prepend the
// common tool locations to the inherited PATH, de-duplicated. Order is
// preserved; the prepended dirs win over inherited ones.
func augmentInherited(home, inherited string) string {
var parts []string
seen := map[string]bool{}
push := func(p string) {
if p != "" && !seen[p] {
seen[p] = true
parts = append(parts, p)
}
}
push("/opt/homebrew/bin")
push("/opt/homebrew/sbin")
push("/usr/local/bin")
push("/usr/local/go/bin")
if home != "" {
push(filepath.Join(home, "go/bin"))
push(filepath.Join(home, ".local/bin"))
push(filepath.Join(home, ".fleetctl"))
}
for _, e := range strings.Split(inherited, ":") {
push(e)
}
return strings.Join(parts, ":")
}