Skip to content

feat: validate a configuration before it replaces the running one - #2661

Open
nicolas-grekas wants to merge 1 commit into
php:mainfrom
nicolas-grekas:config-preflight
Open

nicolas-grekas wants to merge 1 commit into
php:mainfrom
nicolas-grekas:config-preflight

Conversation

@nicolas-grekas

Copy link
Copy Markdown
Contributor

Start() calls Shutdown() before Init(), since the PHP runtime is a process singleton, so a declaration error takes the site down: Caddy rolls back the configuration, not the runtime that went with it, and the server answers 500 until the next reload. A missing worker file does it, so does a name two workers share, or a thread budget that does not add up.

frankenphp.Validate() takes the options Init() takes and reports what it would refuse, touching nothing: the thread budget, the worker files, and the names and scopes workers may take. The rules are the ones Init() runs, newWorker() now shares them instead of holding its own copy.

The Caddy app implements caddy.Validator on top of it, which Caddy calls while the previous configuration still serves:

POST /load with a worker file that does not exist
{"error":"loading config: ... frankenphp: invalid configuration: worker filename is invalid \"...\": no such file or directory"}

and the site that was running keeps serving, which TestRejectedReloadKeepsThePreviousSiteServing checks by counting the requests its worker served across the rejected reload.

Collecting the options moved out of Start() for that, and the names workers take are uniquified per collection rather than per app, so validating a configuration no longer changes the names the next one gets.

Reported by @henderkes in #2617, where duplicate worker names added a trigger to a failure mode that predates it: #2617 (comment)

Start() calls Shutdown() before Init(), since the PHP runtime is a process
singleton, so a declaration error takes the site down and Caddy rolls back
the configuration, not the runtime that went with it: a missing worker
file, a name two workers share, a thread budget that does not add up, any
of them leaves the server answering 500 until the next reload.

frankenphp.Validate() takes the options Init() takes and reports what it
would refuse, touching nothing: the thread budget, the worker files, and
the names and scopes workers may take. The rules are the ones Init() runs,
newWorker() now shares them rather than holding its own copy.

The Caddy app implements caddy.Validator on it, which Caddy calls while
the previous configuration still serves, so a rejected reload keeps the
site up. Collecting the options moved out of Start() for that, and the
names workers take are uniquified per collection rather than per app, so
validating a configuration does not change the names the next one gets.
Comment thread caddy/app.go
close(f.started)
}()

opts, err := f.collectOptions(caddy.NewReplacer(), true)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

configureHotReload calls WithHotReload during provisioning so this is going to discard that, I think that's why CI is red

Comment thread caddy/app.go
// can report anything, and Caddy rolls back the configuration, not the PHP
// runtime that went with it.
func (f *FrankenPHPApp) Validate() error {
opts, err := f.collectOptions(caddy.NewReplacer(), false)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if this ends up called before provisioning (see last comment) this is going to ignore php_server block works, and if one is incorrectly configured, it'll pass validation but still fail actual reload

Comment thread worker.go
Comment on lines 138 to 146
if o.server == nil {
if globalWorkersByPath[absFileName] != nil {
return nil, fmt.Errorf("two global workers cannot have the same filename: %q", absFileName)
if takenGlobalPaths[o.fileName] {
return fmt.Errorf("two global workers cannot have the same filename: %q", o.fileName)
}

// no server means no set of requests to match against, the matcher would never run
if o.matchRequest != nil {
return nil, fmt.Errorf("worker %q has a request matcher but no server scope, use WithWorkerServerScope()", o.name)
return fmt.Errorf("worker %q has a request matcher but no server scope, use WithWorkerServerScope()", o.name)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this only checks worker name duplicates for global workers (o.server == nil)... so what about php_server module workers? I assume two will pass check but fail init?

@henderkes

Copy link
Copy Markdown
Contributor

Also, I did not read the comments during this review... too many comments, too many of them useless.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

馃煛 Changes recommended

Validation misses per-server duplicate worker files, and worker initialization now has quadratic map-copying overhead.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 2 Medium severity

Open (2)
What changed in this PR

Adds preflight configuration validation so invalid Caddy reloads do not stop the running PHP runtime.

Changes:

  • Adds frankenphp.Validate() and shared worker validation.
  • Implements Caddy鈥檚 configuration validator.
  • Adds unit and reload regression tests.
File Description
worker.go Extracts worker file and declaration validation.
frankenphp.go Adds Validate() and defers worker metrics.
frankenphp_test.go Tests declaration validation errors.
docs/鈥媗ibrary.md Documents the validation API.
caddy/鈥媋pp.go Adds validation and refactors option collection.
caddy/鈥媋dmin_test.go Tests rejected reload continuity.
caddy/鈥媍onfig_test.go Updates worker-name tests.
caddy/鈥媠erveridx_test.go Updates server collection tests.

馃挕 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread frankenphp.go
Comment thread worker.go
Comment on lines +162 to +169
takenNames := make(map[string]bool, len(workersByName))
for name := range workersByName {
takenNames[name] = true
}
takenGlobalPaths := make(map[string]bool, len(globalWorkersByPath))
for path := range globalWorkersByPath {
takenGlobalPaths[path] = true
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants