acmeserver: say when the CA database is locked by another process (#8007)

Opening the bbolt database only reported timeout, which sounds like a
broken config. Catch bolt.ErrTimeout with errors.Is and say another
process holds the lock, and that caddy reload is the right command when
Caddy is already running. The error stays fatal.
pull/8011/head
Faiyaz Rahman 2026-09-11 04:49:31 +00:00 committed by GitHub
parent 56ae39bdcc
commit 425a3381fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 52 additions and 1 deletions

2
go.mod
View File

@ -165,7 +165,7 @@ require (
github.com/slackhq/nebula v1.10.3 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/urfave/cli v1.22.17 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.etcd.io/bbolt v1.4.3
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0

View File

@ -16,6 +16,7 @@ package acmeserver
import (
"context"
"errors"
"fmt"
weakrand "math/rand/v2"
"net"
@ -34,6 +35,7 @@ import (
"github.com/smallstep/certificates/authority/provisioner"
"github.com/smallstep/certificates/db"
"github.com/smallstep/nosql"
bolterrors "go.etcd.io/bbolt/errors"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
@ -290,6 +292,10 @@ func (ash Handler) openDatabase() (*db.AuthDB, error) {
DataSource: dbPath,
}
database, err := db.New(dbConfig)
if errors.Is(err, bolterrors.ErrTimeout) {
// bbolt holds an exclusive file lock, so something else has this database open
err = fmt.Errorf("%w: CA database %s is already locked; this is usually another running Caddy instance, in which case use 'caddy reload' to apply config changes to it", err, dbPath)
}
return databaseCloser{&database}, err
})

View File

@ -1,9 +1,15 @@
package acmeserver
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
bolt "go.etcd.io/bbolt"
bolterrors "go.etcd.io/bbolt/errors"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
@ -92,3 +98,42 @@ func TestHandler_warnIfPolicyAllowsAll(t *testing.T) {
})
}
}
func TestHandler_openDatabaseLocked(t *testing.T) {
if testing.Short() {
t.Skip("skipping: bbolt waits 5s for the lock")
}
dataDir := t.TempDir()
t.Setenv("XDG_DATA_HOME", dataDir)
ash := Handler{CA: "locked-db-test", logger: zap.NewNop()}
key := ash.getDatabaseKey()
dbFolder := filepath.Join(dataDir, "caddy", "acme_server", key)
if err := os.MkdirAll(dbFolder, 0o755); err != nil {
t.Fatalf("making database folder: %v", err)
}
dbPath := filepath.Join(dbFolder, "db")
// hold the lock like a running Caddy instance would
locked, err := bolt.Open(dbPath, 0o600, &bolt.Options{Timeout: 5 * time.Second})
if err != nil {
t.Fatalf("opening database: %v", err)
}
defer locked.Close()
_, err = ash.openDatabase()
if err == nil {
t.Fatal("expected an error opening a locked database, got none")
}
if !errors.Is(err, bolterrors.ErrTimeout) {
t.Errorf("expected error to wrap bolterrors.ErrTimeout, got: %v", err)
}
if !strings.Contains(err.Error(), "already locked") {
t.Errorf("expected error to explain the lock, got: %v", err)
}
if _, err := databasePool.Delete(key); err != nil {
t.Errorf("cleaning up database pool: %v", err)
}
}