Files
Andrey Kizimenko 8c6bedf661 Hangar: local dev environment — multi-server + SCEP, MDM assets & TUF tabs (#49454)
<!-- Add the related story/sub-task/bug number, like Resolves #123, or
remove if NA -->
**Related issue:** N/A — internal developer tooling (`tools/hangar`).

## Summary

Fleet Hangar is the local dev-environment control panel (`tools/hangar`,
Go + Wails). This PR expands it into a broader **local dev-services**
toolkit for contributors/QA:

- **Multi-server support** — run up to 3 independent local Fleet servers
in parallel, each on its own git worktree, offset ports, and docker
compose project (server switcher + server-scoped
Server/Logs/Database/Git tabs).
- **SCEP tab** — run local SCEP CA servers using the in-repo
`server/mdm/scep/cmd/scepserver` (built once to a cached binary).
Per-depot profiles, `ca -init`, concurrent start/stop with live logs,
and one-click copy for the SCEP URL / challenge / thumbprint (parsed
from `ca.pem`).
- **MDM assets tab** — run `tools/mdm/assets export` from saved configs;
results list each written file with copy-contents/path + size +
timestamp, plus the `FLEET_MDM_APPLE_*` env block.
- **TUF tab** — drive `tools/tuf/test/main.sh` from platform checkboxes.
Hangar runs the file-server itself (`SKIP_SERVER=1`) so `fleetctl
package` can reach the TUF URL during packaging; streams live build
output; shows ngrok tunnel + TUF-server prerequisites; and offers
kill-server + delete-assets.
- **Supporting work** — DB backups in app-data + cross-server restore;
ngrok live public-URL links + stale-tunnel heal; per-server
open-in-browser; Settings → Troubleshoot cards to reap stray
`scepserver`/TUF-server processes and delete `test_tuf`.

Opening as a **draft for transparency**. All changes are confined to
`tools/hangar/`; nothing touches the Fleet server, agent, or any shipped
code.

**Architecture:** each tab is an `internal/<feature>` package (pure,
unit-tested logic) behind a thin `services/<feature>_service.go` Wails
adapter, reached from the UI as `api.*`. Long-running processes go
through the shared process engine; everything builds from / runs against
the primary repo (Server 1).

# Checklist for submitter

If some of the following don't apply, delete the relevant line.

- [ ] Changes file added for user-visible changes — N/A: `tools/hangar`
is a developer tool and is not part of a Fleet release.

- [x] Input data is properly validated, `SELECT *` is avoided, SQL
injection is prevented, JS inline code is prevented, and untrusted data
interpolated into shell scripts/commands is validated against shell
metacharacters.
- External commands (`scepserver`, `go run ./tools/mdm/assets`, `bash
main.sh`, the backup/restore `docker` invocation) are spawned with
discrete argv slices via the process engine — no shell string
interpolation — so user-supplied values (challenge, enroll secret,
depot/dir paths) can't inject. Backup names are validated to
`[A-Za-z0-9._-]`; server-id path segments are sanitized to
`[A-Za-z0-9_-]` (no traversal); TUF asset deletion is scoped to
`<repo>/test_tuf`.
- [x] Timeouts are implemented and retries are limited to avoid infinite
loops
- Binary builds / one-shot commands run under bounded
`context.WithTimeout`; the TUF-server readiness and ngrok local-API
fetches use short HTTP timeouts; no unbounded loops or retries were
added.
- [ ] If paths of existing endpoints are modified without backwards
compatibility, checked the frontend/CLI — N/A: no Fleet server API
changes.

## Testing

- [x] Added/updated automated tests
- Go unit tests across the new packages: `settings` (SCEP profiles, TUF
config, `migrate` incl. the empty-`servers` case), `scep` (depot/CA
parsing, arg builders), `mdmassets` (export args, `wrote … in …`
parsing, config persistence), `tuf` (env building, file-server args,
asset delete), and `troubleshoot` (live-PID filtering) — plus the
existing backups logic.
  - `tsc --noEmit` clean and `task build` green.
- [ ] Where appropriate, automated tests simulate multiple hosts and
test for host isolation — N/A.
- [x] QA'd all new/changed functionality manually (ongoing local testing
of all three tabs).

## Database migrations

N/A — no database migrations.

## New Fleet configuration settings

N/A — no Fleet server configuration settings (Hangar stores its own
settings in app-data).

## fleetd/orbit/Fleet Desktop

N/A — no fleetd/orbit/Fleet Desktop changes.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Multi-server support (up to three) with server switcher, server-scoped
health/logs, and server-scoped Docker Compose controls.
* New **Servers** settings section plus per-server configuration
(including ports/compose project) and server-aware start/stop/quit
flows.
* New **SCEP**, **MDM Assets**, and **TUF** tabs for managing
profiles/assets and discovering ngrok URLs.
  * Git worktree listing/creation/removal.
  * Centralized, server-scoped database backup management.
* **Bug Fixes**
* Improved process discovery to skip dead or racing entries and avoid
duplicate docker-compose-up display.
  * Self-healing pruning of stale ngrok tunnel selections.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-08-05 17:48:34 -05:00

235 lines
6.1 KiB
Go

package processes
import (
"bytes"
"encoding/json"
"errors"
"os/exec"
"strings"
"github.com/fleetdm/fleet/tools/hangar/internal/shellpath"
)
// dockerCmd builds a `docker ...` command with the login-shell PATH applied
// (docker lives in /usr/local/bin or /opt/homebrew/bin, neither on a
// Finder-launched app's bare PATH).
func dockerCmd(args ...string) *exec.Cmd {
return shellpath.Command("docker", args...)
}
// composeArgs prefixes a `compose [-p project] <rest...>` argv. An empty
// project falls back to docker's default (the cwd's basename), preserving
// single-server behavior.
func composeArgs(project string, rest ...string) []string {
args := []string{"compose"}
if project != "" {
args = append(args, "-p", project)
}
return append(args, rest...)
}
// composeProjectFromArgs extracts the `-p` / `--project-name` value from a
// stored compose argv, so a teardown can target the same project the spawn
// used. Returns "" when none is present.
func composeProjectFromArgs(args []string) string {
for i, a := range args {
if a == "-p" || a == "--project-name" {
if i+1 < len(args) {
return args[i+1]
}
}
if v, ok := strings.CutPrefix(a, "--project-name="); ok {
return v
}
}
return ""
}
func str(v any) string {
s, _ := v.(string)
return s
}
// DockerComposeStatus runs `docker compose [-p project] ps --format json` in
// cwd. A ran-but-failed command (e.g. no compose file) yields "not running";
// only a spawn failure (docker missing) is an error. project scopes the query
// to one server's stack; "" uses the default (cwd-basename) project.
func DockerComposeStatus(cwd, project string) (DockerStatus, error) {
cmd := dockerCmd(composeArgs(project, "ps", "--format", "json")...)
cmd.Dir = cwd
out, err := cmd.Output()
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return DockerStatus{Running: false, Containers: []ContainerState{}}, nil
}
return DockerStatus{}, err
}
containers := []ContainerState{}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var v map[string]any
if json.Unmarshal([]byte(line), &v) != nil {
continue
}
name := str(v["Service"])
if name == "" {
name = str(v["Name"])
}
if name != "" {
containers = append(containers, ContainerState{Name: name, State: str(v["State"])})
}
}
running := false
for _, c := range containers {
if c.State == "running" {
running = true
break
}
}
return DockerStatus{Running: running, Containers: containers}, nil
}
// DockerComposeRestart runs `docker compose [-p project] restart` in cwd.
func DockerComposeRestart(cwd, project string) (string, error) {
cmd := dockerCmd(composeArgs(project, "restart")...)
cmd.Dir = cwd
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
return "", errors.New(stderr.String())
}
return "", err
}
return stdout.String(), nil
}
// dockerComposeProcID is the legacy single-server compose row id, kept as the
// default when a caller doesn't pass an explicit (per-server) id.
const dockerComposeProcID = "docker-compose-up"
// DockerComposeDown runs `docker compose [-p project] down` in cwd, flipping
// the given compose row (id) to stopping for the duration so the UI doesn't
// flash "not running" while containers tear down. id is the per-server
// `<serverID>:docker-compose-up` process id; an empty id targets the legacy
// default row.
func (m *Manager) DockerComposeDown(id, cwd, project string) (string, error) {
if id == "" {
id = dockerComposeProcID
}
m.setComposeState(id, "stopping", true, false)
m.emitState(id, "stopping", nil, nil)
cmd := dockerCmd(composeArgs(project, "down")...)
cmd.Dir = cwd
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
runErr := cmd.Run()
m.setComposeState(id, "done", false, true)
m.emitState(id, "done", nil, nil)
if runErr != nil {
var ee *exec.ExitError
if errors.As(runErr, &ee) {
return "", errors.New(stderr.String())
}
return "", runErr
}
return stdout.String(), nil
}
// setComposeState mutates the given compose row if present. When stamp is
// true it also sets ended_at.
func (m *Manager) setComposeState(id, state string, userStopped, stamp bool) {
m.stateMu.Lock()
defer m.stateMu.Unlock()
info := m.procs[id]
if info == nil {
return
}
info.State = state
if userStopped {
info.WasUserStopped = true
}
if stamp {
ended := nowMS()
info.EndedAtMS = &ended
}
}
// dockerComposeDownFor tears down compose for a managed docker process id
// (called from signalStop). `up -d` usually exits before the user quits, so
// the row is often "done" already — running down here is idempotent.
func (m *Manager) dockerComposeDownFor(id string) error {
m.stateMu.Lock()
var cwd, project string
if info := m.procs[id]; info != nil {
cwd = info.Cwd
}
if a, ok := m.lastArgs[id]; ok {
project = composeProjectFromArgs(a.Args)
}
m.stateMu.Unlock()
if cwd == "" {
return nil
}
cmd := dockerCmd(composeArgs(project, "down")...)
cmd.Dir = cwd
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
runErr := cmd.Run()
success := runErr == nil
var exitCode *int
if cmd.ProcessState != nil {
c := cmd.ProcessState.ExitCode()
if c >= 0 {
exitCode = &c
}
}
final := "failed"
if success {
final = "done"
}
m.stateMu.Lock()
if info := m.procs[id]; info != nil {
for _, line := range append(splitLines(stdout.String()), splitLines(stderr.String())...) {
info.RecentLog = append(info.RecentLog, line)
}
if len(info.RecentLog) > logTailCap {
info.RecentLog = info.RecentLog[len(info.RecentLog)-logTailCap:]
}
info.State = final
info.ExitCode = exitCode
ended := nowMS()
info.EndedAtMS = &ended
info.WasUserStopped = true
}
delete(m.pids, id)
m.stateMu.Unlock()
m.emitState(id, final, exitCode, nil)
m.finishLifecycle(id)
return nil
}
func splitLines(s string) []string {
if s == "" {
return nil
}
var out []string
for _, l := range strings.Split(s, "\n") {
if l != "" {
out = append(out, l)
}
}
return out
}