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

294 lines
9.5 KiB
Go

package settings
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestDefault(t *testing.T) {
d := Default()
if !d.FleetServe.Premium || !d.FleetServe.Debug || !d.FleetServe.Logging {
t.Errorf("serve flags should default true: %+v", d.FleetServe)
}
if d.PythonServer.Port != 8000 {
t.Errorf("python port = %d, want 8000", d.PythonServer.Port)
}
if d.Theme != ThemeSystem {
t.Errorf("theme = %q, want system", d.Theme)
}
if d.FirstRunComplete {
t.Error("first_run_complete should default false")
}
}
func TestLoadMissingFileIsDefault(t *testing.T) {
got, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if got.PythonServer.Port != 8000 || !got.FleetServe.Premium {
t.Errorf("missing file should yield defaults, got %+v", got)
}
}
func TestSaveLoadRoundTrip(t *testing.T) {
dir := t.TempDir()
rp := "/Users/tester/fleet"
in := Default()
in.RepoPath = &rp
in.FirstRunComplete = true
in.Theme = ThemeDark
in.FleetServe.Premium = false
in.FleetServe.Env = []EnvVar{{Key: "FOO", Value: "bar", Enabled: false}}
if err := Save(dir, in); err != nil {
t.Fatal(err)
}
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if got.RepoPath == nil || *got.RepoPath != rp {
t.Errorf("repo_path round-trip failed: %v", got.RepoPath)
}
if got.Theme != ThemeDark || got.FleetServe.Premium != false {
t.Errorf("round-trip mismatch: theme=%q premium=%v", got.Theme, got.FleetServe.Premium)
}
if len(got.FleetServe.Env) != 1 || got.FleetServe.Env[0].Enabled != false {
t.Errorf("env round-trip failed: %+v", got.FleetServe.Env)
}
}
// An existing file missing the serve flags must load them as true (serde
// default parity), and an EnvVar row missing "enabled" must default true.
func TestLoadDefaultsForMissingFields(t *testing.T) {
dir := t.TempDir()
partial := `{
"repo_path": "/x",
"fleet_serve": { "config_path": null, "env": [ { "key": "A", "value": "1" } ] }
}`
if err := os.WriteFile(filepath.Join(dir, fileName), []byte(partial), 0o644); err != nil {
t.Fatal(err)
}
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if !got.FleetServe.Premium || !got.FleetServe.Debug || !got.FleetServe.Logging {
t.Errorf("missing serve flags should default true, got %+v", got.FleetServe)
}
if got.PythonServer.Port != 8000 {
t.Errorf("missing python_server should default port 8000, got %d", got.PythonServer.Port)
}
if got.Theme != ThemeSystem {
t.Errorf("missing theme should default system, got %q", got.Theme)
}
if len(got.FleetServe.Env) != 1 || !got.FleetServe.Env[0].Enabled {
t.Errorf("env row missing 'enabled' should default true, got %+v", got.FleetServe.Env)
}
}
// A hand-edited file with an empty "servers": [] must not panic on Load — the
// ActiveServerID repair in migrate indexes Servers[0], so an empty array has to
// synthesize server 1 just like a nil/absent one.
func TestLoadEmptyServersArray(t *testing.T) {
dir := t.TempDir()
data := `{ "repo_path": "/x", "servers": [] }`
if err := os.WriteFile(filepath.Join(dir, fileName), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
got, err := Load(dir) // must not panic
if err != nil {
t.Fatal(err)
}
if len(got.Servers) != 1 {
t.Fatalf("empty servers array should synthesize 1 server, got %d", len(got.Servers))
}
if got.ActiveServerID != got.Servers[0].ID {
t.Errorf("active_server_id = %q, want %q", got.ActiveServerID, got.Servers[0].ID)
}
}
// An explicit false in the file must win over the true default.
func TestLoadExplicitFalseWins(t *testing.T) {
dir := t.TempDir()
data := `{ "fleet_serve": { "premium": false, "debug": false, "logging_debug": false } }`
if err := os.WriteFile(filepath.Join(dir, fileName), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if got.FleetServe.Premium || got.FleetServe.Debug || got.FleetServe.Logging {
t.Errorf("explicit false should win, got %+v", got.FleetServe)
}
}
// A fresh install (missing file) must come back with exactly one enabled
// server on the canonical ports, active.
func TestLoadMissingFileSynthesizesServer(t *testing.T) {
got, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if len(got.Servers) != 1 {
t.Fatalf("want 1 server, got %d", len(got.Servers))
}
s := got.Servers[0]
if s.ID != "s1" || !s.Enabled || s.WorktreePath != nil {
t.Errorf("default server unexpected: %+v", s)
}
if s.Ports != (ServerPorts{Server: 8080, MySQL: 3306, Redis: 6379, S3: 9000, S3Console: 9001}) {
t.Errorf("server 1 should use canonical ports, got %+v", s.Ports)
}
if s.ComposeProject != "fleet" {
t.Errorf("server 1 compose project = %q, want fleet", s.ComposeProject)
}
if got.ActiveServerID != "s1" {
t.Errorf("active server = %q, want s1", got.ActiveServerID)
}
}
// A legacy single-server file (repo_path + fleet_serve, no servers array) must
// migrate into one server: repo_path becomes the worktree, and the saved serve
// config carries over.
func TestLoadMigratesLegacySingleServer(t *testing.T) {
dir := t.TempDir()
legacy := `{
"repo_path": "/Users/tester/fleet",
"first_run_complete": true,
"fleet_serve": { "premium": false, "debug": true, "logging_debug": true, "env": [ { "key": "A", "value": "1" } ] }
}`
if err := os.WriteFile(filepath.Join(dir, fileName), []byte(legacy), 0o644); err != nil {
t.Fatal(err)
}
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if len(got.Servers) != 1 {
t.Fatalf("want 1 migrated server, got %d", len(got.Servers))
}
s := got.Servers[0]
if s.WorktreePath == nil || *s.WorktreePath != "/Users/tester/fleet" {
t.Errorf("legacy repo_path should become worktree, got %v", s.WorktreePath)
}
if s.FleetServe.Premium != false {
t.Errorf("legacy serve premium=false should carry over, got %+v", s.FleetServe)
}
if len(s.FleetServe.Env) != 1 || s.FleetServe.Env[0].Key != "A" {
t.Errorf("legacy serve env should carry over, got %+v", s.FleetServe.Env)
}
if got.ActiveServerID != "s1" {
t.Errorf("active server = %q, want s1", got.ActiveServerID)
}
}
// A file already in multi-server shape must round-trip untouched (migration is
// idempotent) and must NOT be clobbered by the legacy fields.
func TestLoadMultiServerRoundTrip(t *testing.T) {
dir := t.TempDir()
wp1, wp2 := "/a/fleet", "/a/fleet-n1"
in := Default()
migrate(&in) // shape it like a loaded config
in.Servers = []ServerProfile{
{ID: "s1", Name: "main", Color: "green", WorktreePath: &wp1, Ports: DefaultPortsForIndex(0), ComposeProject: "fleet", FleetServe: FleetServeConfig{Premium: true, Env: []EnvVar{}}, Enabled: true},
{ID: "s2", Name: "n-1", Color: "purple", WorktreePath: &wp2, Ports: DefaultPortsForIndex(1), ComposeProject: "fleet-s2", FleetServe: FleetServeConfig{Premium: false, Env: []EnvVar{}}, Enabled: true},
}
in.ActiveServerID = "s2"
if err := Save(dir, in); err != nil {
t.Fatal(err)
}
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if len(got.Servers) != 2 {
t.Fatalf("want 2 servers, got %d", len(got.Servers))
}
if got.ActiveServerID != "s2" {
t.Errorf("active server = %q, want s2", got.ActiveServerID)
}
if got.Servers[1].Ports.MySQL != 3326 || got.Servers[1].ComposeProject != "fleet-s2" {
t.Errorf("server 2 ports/project wrong: %+v", got.Servers[1])
}
}
// A dangling active_server_id (points at a removed server) is repaired to the
// first server.
func TestLoadRepairsDanglingActiveServer(t *testing.T) {
dir := t.TempDir()
data := `{ "servers": [ { "id": "s1", "name": "main", "enabled": true } ], "active_server_id": "s9" }`
if err := os.WriteFile(filepath.Join(dir, fileName), []byte(data), 0o644); err != nil {
t.Fatal(err)
}
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if got.ActiveServerID != "s1" {
t.Errorf("dangling active server should fall back to s1, got %q", got.ActiveServerID)
}
// A server with only id/name/enabled set must get ports/project/color backfilled.
if got.Servers[0].Ports.MySQL != 3306 || got.Servers[0].ComposeProject != "fleet" || got.Servers[0].Color == "" {
t.Errorf("partial server not backfilled: %+v", got.Servers[0])
}
}
func TestDefaultPortsForIndex(t *testing.T) {
cases := []struct {
i int
server, mysql, redis, s3, s3console uint16
}{
{0, 8080, 3306, 6379, 9000, 9001},
{1, 8090, 3326, 6389, 9020, 9011},
{2, 8100, 3346, 6399, 9040, 9021},
}
for _, c := range cases {
got := DefaultPortsForIndex(c.i)
want := ServerPorts{Server: c.server, MySQL: c.mysql, Redis: c.redis, S3: c.s3, S3Console: c.s3console}
if got != want {
t.Errorf("ports[%d] = %+v, want %+v", c.i, got, want)
}
}
}
func TestNextServerProfile(t *testing.T) {
// From one server we get s2 on the offset block.
one := []ServerProfile{defaultServer(0)}
p, ok := NextServerProfile(one)
if !ok {
t.Fatal("expected a profile for slot 2")
}
if p.ID != "s2" || p.Ports.MySQL != 3326 || p.ComposeProject != "fleet-s2" {
t.Errorf("next profile wrong: %+v", p)
}
// At the cap we get false.
full := []ServerProfile{defaultServer(0), defaultServer(1), defaultServer(2)}
if _, ok := NextServerProfile(full); ok {
t.Errorf("expected no profile past the cap of %d", MaxServers)
}
}
func TestSavedJSONKeys(t *testing.T) {
dir := t.TempDir()
if err := Save(dir, Default()); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(dir, fileName))
if err != nil {
t.Fatal(err)
}
for _, key := range []string{
"\"repo_path\"", "\"fleetctl_path\"", "\"gitops_dir\"", "\"first_run_complete\"",
"\"python_server\"", "\"fleet_serve\"", "\"logging_debug\"", "\"favorite_crons\"",
} {
if !strings.Contains(string(raw), key) {
t.Errorf("saved settings missing key %s", key)
}
}
}