Skip to content

Commit 3b370ed

Browse files
feat: validate a configuration before it replaces the running one (#2661)
1 parent 9c6aef0 commit 3b370ed

12 files changed

Lines changed: 425 additions & 82 deletions

‎caddy/admin_test.go‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"fmt"
77
"io"
88
"net/http"
9+
"strconv"
910
"strings"
1011
"sync"
1112
"testing"
@@ -418,3 +419,141 @@ func TestRegisteredModuleWorkerPoolsMustBeCorrect(t *testing.T) {
418419
assert.Contains(t, receivedThreadNames, "Worker PHP Thread - "+worker2Path, "expected module worker with \"match\" directive to be present")
419420
assert.Contains(t, receivedThreadNames, "Worker PHP Thread - "+worker3Path, "expected module worker without \"match\" directive to be present")
420421
}
422+
423+
// Validate() must reject incorrect configs before Start() calls frankenphp.Shutdown()
424+
func TestRejectedReloadKeepsThePreviousSiteServing(t *testing.T) {
425+
tester := caddytest.NewTester(t)
426+
initServer(t, tester, `
427+
{
428+
skip_install_trust
429+
admin localhost:2999
430+
http_port `+testPort+`
431+
432+
frankenphp {
433+
worker ../testdata/worker-with-counter.php 1
434+
}
435+
}
436+
437+
localhost:`+testPort+` {
438+
route {
439+
root ../testdata
440+
rewrite worker-with-counter.php
441+
php
442+
}
443+
}
444+
`, "caddyfile")
445+
446+
workerURL := "http://localhost:" + testPort + "/worker-with-counter.php"
447+
servedBefore := countedRequests(t, workerURL)
448+
449+
// the worker file does not exist, which Init() only reports once the
450+
// running configuration is gone
451+
rejected := `
452+
{
453+
skip_install_trust
454+
admin localhost:2999
455+
http_port ` + testPort + `
456+
457+
frankenphp {
458+
worker ../testdata/not-a-worker.php 1
459+
}
460+
}
461+
462+
localhost:` + testPort + ` {
463+
route {
464+
root ../testdata
465+
rewrite worker-with-counter.php
466+
php
467+
}
468+
}
469+
`
470+
471+
r, err := http.NewRequest("POST", "http://localhost:2999/load", bytes.NewBufferString(rejected))
472+
require.NoError(t, err)
473+
r.Header.Set("Content-Type", "text/caddyfile")
474+
resp, err := http.DefaultClient.Do(r)
475+
require.NoError(t, err)
476+
body, err := io.ReadAll(resp.Body)
477+
require.NoError(t, err)
478+
require.NoError(t, resp.Body.Close())
479+
// /load writes the adaptation warnings before loading, so the status is
480+
// already 200 when the error follows them in the body
481+
require.Contains(t, string(body), "invalid configuration: worker filename is invalid")
482+
483+
// the runtime that served before the rejected reload still serves, and
484+
// it is the same one: its worker kept counting
485+
require.Equal(t, servedBefore+1, countedRequests(t, workerURL))
486+
}
487+
488+
// Validate() cannot see a worker declared in a php_server block: Caddy calls it
489+
// before the modules provision, so Start() must reject those before shutting down
490+
func TestRejectedReloadWithAModuleWorkerKeepsThePreviousSiteServing(t *testing.T) {
491+
tester := caddytest.NewTester(t)
492+
initServer(t, tester, `
493+
{
494+
skip_install_trust
495+
admin localhost:2999
496+
http_port `+testPort+`
497+
}
498+
499+
localhost:`+testPort+` {
500+
route {
501+
root ../testdata
502+
rewrite worker-with-counter.php
503+
php {
504+
worker ../testdata/worker-with-counter.php 1
505+
}
506+
}
507+
}
508+
`, "caddyfile")
509+
510+
workerURL := "http://localhost:" + testPort + "/worker-with-counter.php"
511+
servedBefore := countedRequests(t, workerURL)
512+
513+
rejected := `
514+
{
515+
skip_install_trust
516+
admin localhost:2999
517+
http_port ` + testPort + `
518+
}
519+
520+
localhost:` + testPort + ` {
521+
route {
522+
root ../testdata
523+
rewrite worker-with-counter.php
524+
php {
525+
worker ../testdata/not-a-worker.php 1
526+
}
527+
}
528+
}
529+
`
530+
531+
r, err := http.NewRequest("POST", "http://localhost:2999/load", bytes.NewBufferString(rejected))
532+
require.NoError(t, err)
533+
r.Header.Set("Content-Type", "text/caddyfile")
534+
resp, err := http.DefaultClient.Do(r)
535+
require.NoError(t, err)
536+
body, err := io.ReadAll(resp.Body)
537+
require.NoError(t, err)
538+
require.NoError(t, resp.Body.Close())
539+
require.Contains(t, string(body), "worker filename is invalid")
540+
require.Equal(t, servedBefore+1, countedRequests(t, workerURL))
541+
}
542+
543+
// the number of requests testdata/worker-with-counter.php has served
544+
func countedRequests(t *testing.T, workerURL string) int {
545+
t.Helper()
546+
547+
resp, err := http.Get(workerURL)
548+
require.NoError(t, err)
549+
defer func() { require.NoError(t, resp.Body.Close()) }()
550+
require.Equal(t, http.StatusOK, resp.StatusCode)
551+
552+
body, err := io.ReadAll(resp.Body)
553+
require.NoError(t, err)
554+
555+
count, err := strconv.Atoi(strings.TrimPrefix(string(body), "requests:"))
556+
require.NoError(t, err, "unexpected worker response %q", body)
557+
558+
return count
559+
}

‎caddy/app.go‎

Lines changed: 90 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -60,15 +60,15 @@ type FrankenPHPApp struct {
6060
// EXPERIMENTAL: MaxRequests sets the maximum number of requests a PHP thread handles before restarting (0 = unlimited)
6161
MaxRequests int `json:"max_requests,omitempty"`
6262

63-
opts []frankenphp.Option
64-
metrics frankenphp.Metrics
65-
ctx context.Context
66-
logger *slog.Logger
67-
modules []*FrankenPHPModule
68-
usedWorkerNames map[string]bool
69-
httpApp *caddyhttp.App
70-
hasStarted atomic.Bool
71-
started chan any
63+
opts []frankenphp.Option
64+
provisionOpts []frankenphp.Option
65+
metrics frankenphp.Metrics
66+
ctx context.Context
67+
logger *slog.Logger
68+
modules []*FrankenPHPModule
69+
httpApp *caddyhttp.App
70+
hasStarted atomic.Bool
71+
started chan any
7272
}
7373

7474
var errIni = errors.New(`"php_ini" must be in the format: php_ini "<key>" "<value>"`)
@@ -107,18 +107,29 @@ func (f *FrankenPHPApp) Provision(ctx caddy.Context) error {
107107
return nil
108108
}
109109

110-
func (f *FrankenPHPApp) Start() error {
111-
defer func() {
112-
close(f.started)
113-
}()
110+
// Validate is called by Caddy before Start() to validate before shutting down the currently running config
111+
func (f *FrankenPHPApp) Validate() error {
112+
opts, err := f.collectOptions(caddy.NewReplacer(), false)
113+
if err != nil {
114+
return err
115+
}
114116

115-
repl := caddy.NewReplacer()
117+
return frankenphp.Validate(opts...)
118+
}
116119

120+
// collectOptions turns the configuration into the options Init() takes.
121+
// keep is for the configuration that starts, whose servers the modules
122+
// serve from; Validate() collects those of one that may never start.
123+
func (f *FrankenPHPApp) collectOptions(repl *caddy.Replacer, keep bool) ([]frankenphp.Option, error) {
117124
optionsMU.RLock()
118-
f.opts = append(f.opts, options...)
125+
// We have at least 9 hardcoded options
126+
opts := make([]frankenphp.Option, 0, 9+len(options)+len(f.provisionOpts))
127+
opts = append(opts, options...)
119128
optionsMU.RUnlock()
120129

121-
f.opts = append(f.opts,
130+
opts = append(opts, f.provisionOpts...)
131+
132+
opts = append(opts,
122133
frankenphp.WithContext(f.ctx),
123134
frankenphp.WithLogger(f.logger),
124135
frankenphp.WithNumThreads(f.NumThreads),
@@ -130,18 +141,42 @@ func (f *FrankenPHPApp) Start() error {
130141
frankenphp.WithMaxRequests(f.MaxRequests),
131142
)
132143

144+
usedWorkerNames := make(map[string]bool, len(f.Workers))
145+
133146
// register global workers
134147
for _, w := range f.Workers {
135148
w.FileName = repl.ReplaceKnown(w.FileName, "")
136-
w.Name = f.createUniqueWorkerName(w, "")
137-
opts, err := w.toWorkerOptions()
149+
w.Name = createUniqueWorkerName(usedWorkerNames, w, "")
150+
workerOptions, err := w.toWorkerOptions()
138151
if err != nil {
139-
return err
152+
return nil, err
140153
}
141-
f.opts = append(f.opts, frankenphp.WithWorkers(w.Name, w.FileName, w.Num, opts...))
154+
opts = append(opts, frankenphp.WithWorkers(w.Name, w.FileName, w.Num, workerOptions...))
155+
}
156+
157+
moduleOpts, err := f.collectModuleOptions(repl, usedWorkerNames, keep)
158+
if err != nil {
159+
return nil, err
160+
}
161+
162+
return append(opts, moduleOpts...), nil
163+
}
164+
165+
func (f *FrankenPHPApp) Start() error {
166+
defer func() {
167+
close(f.started)
168+
}()
169+
170+
opts, err := f.collectOptions(caddy.NewReplacer(), true)
171+
if err != nil {
172+
return err
142173
}
174+
f.opts = opts
143175

144-
if err := f.registerModules(repl); err != nil {
176+
// Validate() ran before the modules provisioned, so it saw the global
177+
// block alone: what a php_server declares is checked here, in time for
178+
// the configuration in place to survive a refusal
179+
if err := frankenphp.Validate(f.opts...); err != nil {
145180
return err
146181
}
147182

@@ -178,35 +213,42 @@ func (f *FrankenPHPApp) Stop() error {
178213
}
179214

180215
// register workers and servers for "php" and "php_server" modules
181-
func (f *FrankenPHPApp) registerModules(repl *caddy.Replacer) error {
182-
modulesByIndex := make(map[int]*FrankenPHPModule, len(f.modules))
183-
for _, module := range f.modules {
184-
if module.ServerIndex == 0 {
185-
if err := f.registerModule(repl, module); err != nil {
186-
return err
187-
}
188-
continue
189-
}
216+
func (f *FrankenPHPApp) collectModuleOptions(repl *caddy.Replacer, usedWorkerNames map[string]bool, keep bool) ([]frankenphp.Option, error) {
217+
opts := make([]frankenphp.Option, 0, len(f.modules))
218+
serversByIndex := make(map[int]*frankenphp.Server, len(f.modules))
190219

220+
for _, module := range f.modules {
191221
// modules with the same server_idx should share the same server instance
192222
// example: the worker { match * } rule adds 2 "php" subroutes to the caddy handler
193223
// the 2 handlers belong to the same "php_server" and must therefore share workers
194-
if existingModule, ok := modulesByIndex[module.ServerIndex]; ok {
195-
module.server = existingModule.server
196-
continue
224+
if module.ServerIndex != 0 {
225+
if server, ok := serversByIndex[module.ServerIndex]; ok {
226+
if keep {
227+
module.server = server
228+
}
229+
230+
continue
231+
}
232+
}
233+
234+
server, moduleOpts, err := f.collectModule(repl, module, usedWorkerNames)
235+
if err != nil {
236+
return nil, err
197237
}
198238

199-
modulesByIndex[module.ServerIndex] = module
200-
if err := f.registerModule(repl, module); err != nil {
201-
return err
239+
if keep {
240+
module.server = server
241+
}
242+
if module.ServerIndex != 0 {
243+
serversByIndex[module.ServerIndex] = server
202244
}
245+
opts = append(opts, moduleOpts...)
203246
}
204247

205-
return nil
248+
return opts, nil
206249
}
207250

208-
// register a server instance and its workers for a single Caddy module
209-
func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPModule) error {
251+
func (f *FrankenPHPApp) collectModule(repl *caddy.Replacer, module *FrankenPHPModule, usedWorkerNames map[string]bool) (*frankenphp.Server, []frankenphp.Option, error) {
210252
serverName := f.resolveServerName(module)
211253
server, err := frankenphp.NewServer(
212254
module.resolvedDocumentRoot,
@@ -216,43 +258,38 @@ func (f *FrankenPHPApp) registerModule(repl *caddy.Replacer, module *FrankenPHPM
216258
frankenphp.WithServerLogger(module.logger),
217259
)
218260
if err != nil {
219-
return err
261+
return nil, nil, err
220262
}
221263

222-
module.server = server
223-
f.opts = append(f.opts, frankenphp.WithServer(server))
264+
opts := []frankenphp.Option{frankenphp.WithServer(server)}
224265

225266
for _, w := range module.Workers {
226267
w.FileName = repl.ReplaceKnown(w.FileName, "")
227-
w.Name = f.createUniqueWorkerName(w, serverName)
268+
w.Name = createUniqueWorkerName(usedWorkerNames, w, serverName)
228269
workerOptions, err := w.toWorkerOptions()
229270
if err != nil {
230-
return err
271+
return nil, nil, err
231272
}
232273
workerOptions = append(workerOptions, frankenphp.WithWorkerServerScope(server))
233-
f.opts = append(f.opts, frankenphp.WithWorkers(w.Name, w.FileName, w.Num, workerOptions...))
274+
opts = append(opts, frankenphp.WithWorkers(w.Name, w.FileName, w.Num, workerOptions...))
234275
}
235276

236-
return nil
277+
return server, opts, nil
237278
}
238279

239280
// avoid name collisions for workers
240281
// on collision, a name is first qualified with the server name
241282
// ("<serverName>:<name>") before falling back to a numeric postfix
242-
func (f *FrankenPHPApp) createUniqueWorkerName(wc workerConfig, serverName string) string {
243-
if f.usedWorkerNames == nil {
244-
f.usedWorkerNames = make(map[string]bool)
245-
}
246-
283+
func createUniqueWorkerName(usedWorkerNames map[string]bool, wc workerConfig, serverName string) string {
247284
if wc.Name == "" {
248285
wc.Name, _ = fastabs.FastAbs(wc.FileName)
249286
}
250287

251288
name := wc.Name
252289
suffix := 0
253290
for {
254-
if _, ok := f.usedWorkerNames[name]; !ok {
255-
f.usedWorkerNames[name] = true
291+
if _, ok := usedWorkerNames[name]; !ok {
292+
usedWorkerNames[name] = true
256293
break
257294
}
258295
if serverName != "" {

0 commit comments

Comments
 (0)