Skip to content

Commit b756f44

Browse files
Add ember init command
1 parent 03c4aee commit b756f44

8 files changed

Lines changed: 704 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ Caddy exposes rich metrics through its admin API and Prometheus endpoint, but re
3636
- Quick health check: `ember status` for a one-line Caddy summary
3737
- Readiness gate: `ember wait` blocks until Caddy is up
3838
- Deployment validation: `ember diff before.json after.json` compares snapshots
39+
- Zero-config setup: `ember init` checks Caddy and enables metrics via the admin API
3940
- Auto-detection of FrankenPHP and Caddy processes
4041
- Lightweight: ~15 MB RSS, ~0.3 ms per poll cycle with 100 threads and 10 hosts ([benchmarks](internal/app/daemon_bench_test.go))
4142
- Cross-platform binaries, Homebrew tap, and Docker image

docs/cli-reference.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,32 @@ ember --frankenphp-pid 42
6464

6565
## Subcommands
6666

67+
### `ember init`
68+
69+
Checks that Caddy is properly configured for Ember and offers to enable HTTP metrics via the admin API if they are missing. Does not modify any files on disk.
70+
71+
**What it checks:**
72+
1. Admin API is reachable
73+
2. HTTP servers are configured
74+
3. HTTP metrics directive is enabled
75+
4. FrankenPHP presence, threads, and workers
76+
5. Metrics are actually flowing
77+
78+
**What it can do:**
79+
- Enable the `metrics` directive via `POST /config/apps/http/metrics` (no Caddy restart needed)
80+
81+
**Examples:**
82+
83+
```bash
84+
ember init
85+
ember init --addr https://prod:2019 --ca-cert ca.pem
86+
ember init -y # skip confirmation prompts
87+
```
88+
89+
| Flag | Type | Default | Description |
90+
|------|------|---------|-------------|
91+
| `-y`, `--yes` | bool | `false` | Skip confirmation prompts |
92+
6793
### `ember version`
6894

6995
Prints the current version. With `--check`, queries the GitHub Releases API to see if a newer version is available.

docs/getting-started.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ docker run --rm --network host ghcr.io/alexandre-daubois/ember
2828

2929
> **Note:** The Docker image runs in daemon mode by default. See [Docker](docker.md) for details.
3030
31+
## Setup
32+
33+
Run `ember init` to check your Caddy configuration and enable metrics if needed:
34+
35+
```bash
36+
ember init
37+
```
38+
39+
This verifies that the admin API is reachable, enables the `metrics` directive via the API if missing (no restart required), and detects FrankenPHP.
40+
3141
## First Run
3242

3343
Start Ember:

internal/app/init.go

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package app
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/signal"
10+
"strings"
11+
"syscall"
12+
13+
"github.com/alexandre-daubois/ember/internal/fetcher"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
func newInitCmd(cfg *config) *cobra.Command {
18+
var yes bool
19+
20+
cmd := &cobra.Command{
21+
Use: "init",
22+
Short: "Check and configure Caddy for Ember",
23+
Long: `Verifies that Caddy is reachable, checks if HTTP metrics are enabled,
24+
and offers to enable them via the admin API if missing.
25+
26+
This command does not modify any files on disk. It only uses the Caddy
27+
admin API to read and optionally write configuration.`,
28+
Example: ` ember init
29+
ember init --addr https://prod:2019 --ca-cert ca.pem
30+
ember init -y`,
31+
SilenceUsage: true,
32+
SilenceErrors: true,
33+
RunE: func(cmd *cobra.Command, args []string) error {
34+
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
35+
defer cancel()
36+
ctx, tCancel := contextWithTimeout(ctx, cfg.timeout)
37+
defer tCancel()
38+
39+
f := fetcher.NewHTTPFetcher(cfg.addr, 0)
40+
if err := configureTLS(f, cfg); err != nil {
41+
return err
42+
}
43+
44+
return runInit(ctx, cmd.OutOrStdout(), os.Stdin, f, cfg.addr, yes)
45+
},
46+
}
47+
48+
cmd.Flags().BoolVarP(&yes, "yes", "y", false, "Skip confirmation prompts")
49+
50+
return cmd
51+
}
52+
53+
type initCheck struct {
54+
label string
55+
ok bool
56+
detail string
57+
}
58+
59+
func runInit(ctx context.Context, w io.Writer, r io.Reader, f *fetcher.HTTPFetcher, addr string, autoYes bool) error {
60+
fmt.Fprintf(w, "Checking Caddy at %s...\n", addr)
61+
62+
if err := f.CheckAdminAPI(ctx); err != nil {
63+
printCheck(w, initCheck{label: "Admin API", ok: false, detail: err.Error()})
64+
return fmt.Errorf("caddy admin API is not reachable at %s", addr)
65+
}
66+
printCheck(w, initCheck{label: "Admin API reachable", ok: true})
67+
68+
servers := f.FetchServerNames(ctx)
69+
if len(servers) > 0 {
70+
printCheck(w, initCheck{label: fmt.Sprintf("%d HTTP server(s) configured", len(servers)), ok: true, detail: strings.Join(servers, ", ")})
71+
} else {
72+
printCheck(w, initCheck{label: "No HTTP servers found", ok: false})
73+
}
74+
75+
metricsEnabled, err := f.CheckMetricsEnabled(ctx)
76+
if err != nil {
77+
printCheck(w, initCheck{label: "Metrics check failed", ok: false, detail: err.Error()})
78+
} else if metricsEnabled {
79+
printCheck(w, initCheck{label: "HTTP metrics enabled", ok: true})
80+
} else {
81+
printCheck(w, initCheck{label: "HTTP metrics not enabled", ok: false})
82+
83+
if !promptYesNo(w, r, "\nEnable HTTP metrics via the admin API? Caddy does not need to restart.", autoYes) {
84+
fmt.Fprintln(w, " Skipped. Add the metrics directive to your Caddyfile manually:")
85+
fmt.Fprintln(w, " { metrics }")
86+
} else {
87+
if err := f.EnableMetrics(ctx); err != nil {
88+
printCheck(w, initCheck{label: "Failed to enable metrics", ok: false, detail: err.Error()})
89+
return fmt.Errorf("could not enable metrics: %w", err)
90+
}
91+
printCheck(w, initCheck{label: "HTTP metrics enabled", ok: true})
92+
}
93+
}
94+
95+
fmt.Fprintln(w, "\nChecking FrankenPHP...")
96+
hasFP := f.DetectFrankenPHP(ctx)
97+
if !hasFP {
98+
printCheck(w, initCheck{label: "FrankenPHP not detected (Caddy-only mode)", ok: true})
99+
} else {
100+
printCheck(w, initCheck{label: "FrankenPHP detected", ok: true})
101+
102+
fpCfg, err := f.FetchFrankenPHPConfig(ctx)
103+
if err == nil && fpCfg != nil {
104+
if fpCfg.NumThreads > 0 {
105+
printCheck(w, initCheck{label: fmt.Sprintf("%d threads configured", fpCfg.NumThreads), ok: true})
106+
}
107+
for _, wk := range fpCfg.Workers {
108+
name := wk.FileName
109+
if wk.Name != "" {
110+
name = wk.Name + " (" + wk.FileName + ")"
111+
}
112+
printCheck(w, initCheck{label: fmt.Sprintf("Worker: %s (%d instances)", name, wk.Num), ok: true})
113+
}
114+
}
115+
}
116+
117+
fmt.Fprintln(w, "\nVerifying metrics collection...")
118+
snap, _ := f.Fetch(ctx)
119+
if snap != nil && snap.Metrics.HasHTTPMetrics {
120+
printCheck(w, initCheck{label: "caddy_http_* metrics present", ok: true})
121+
} else if snap != nil {
122+
printCheck(w, initCheck{label: "No HTTP traffic metrics yet", ok: false, detail: "metrics will appear after the first HTTP request"})
123+
}
124+
125+
if snap != nil && hasFP && snap.Metrics.TotalThreads > 0 {
126+
printCheck(w, initCheck{label: fmt.Sprintf("frankenphp_* metrics present (%.0f threads)", snap.Metrics.TotalThreads), ok: true})
127+
}
128+
129+
fmt.Fprintln(w, "\nEmber is ready. Run \"ember\" to start the dashboard.")
130+
return nil
131+
}
132+
133+
func printCheck(w io.Writer, c initCheck) {
134+
marker := " ✓ "
135+
if !c.ok {
136+
marker = " ✗ "
137+
}
138+
if c.detail != "" {
139+
fmt.Fprintf(w, "%s%s (%s)\n", marker, c.label, c.detail)
140+
} else {
141+
fmt.Fprintf(w, "%s%s\n", marker, c.label)
142+
}
143+
}
144+
145+
func promptYesNo(w io.Writer, r io.Reader, prompt string, autoYes bool) bool {
146+
if autoYes {
147+
fmt.Fprintf(w, "%s [Y/n] y\n", prompt)
148+
return true
149+
}
150+
fmt.Fprintf(w, "%s [Y/n] ", prompt)
151+
scanner := bufio.NewScanner(r)
152+
if !scanner.Scan() {
153+
return false
154+
}
155+
answer := strings.TrimSpace(strings.ToLower(scanner.Text()))
156+
return answer == "" || answer == "y" || answer == "yes"
157+
}

0 commit comments

Comments
 (0)