diff --git a/API Server/internal/api/plugins_bootstrap_test.go b/API Server/internal/api/plugins_bootstrap_test.go new file mode 100644 index 0000000..6a3f63b --- /dev/null +++ b/API Server/internal/api/plugins_bootstrap_test.go @@ -0,0 +1,157 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "drivervault/apiserver/internal/config" + "drivervault/apiserver/internal/pb" +) + +// fakeSchemaPB is a PocketBase stand-in for the "app_settings does not exist" +// case: a stack upgraded with PB_BOOTSTRAP off, where the on-boot schema pass +// never created the collection the plugin settings live in. +type fakeSchemaPB struct { + mu sync.Mutex + + created bool // app_settings exists + createCalls int // POST /api/collections + unreachable bool // every records call fails with a 502 +} + +func (f *fakeSchemaPB) server(t *testing.T) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + + switch { + case strings.HasSuffix(r.URL.Path, "/auth-with-password"): + writeTestJSON(w, http.StatusOK, map[string]any{"token": "test-token"}) + + // The settings read. 404 until the collection exists — PocketBase + // answers a list against a missing collection that way, while an + // existing but empty one answers 200 with no items. + case r.Method == http.MethodGet && r.URL.Path == "/api/collections/app_settings/records": + if f.unreachable { + http.Error(w, "connection refused", http.StatusBadGateway) + return + } + if !f.created { + http.Error(w, `{"message":"Missing collection context."}`, http.StatusNotFound) + return + } + writeTestJSON(w, http.StatusOK, map[string]any{ + "page": 1, "perPage": 1, "totalItems": 0, "totalPages": 1, + "items": []any{}, + }) + + // The schema calls EnsureCollection makes. + case r.Method == http.MethodGet && r.URL.Path == "/api/collections": + items := []any{ + map[string]any{"id": "col_users", "name": "users", "fields": []any{ + map[string]any{"name": "email", "type": "text"}, + }}, + } + if f.created { + items = append(items, map[string]any{"id": "col_app", "name": "app_settings"}) + } + writeTestJSON(w, http.StatusOK, map[string]any{"items": items}) + + case r.Method == http.MethodPost && r.URL.Path == "/api/collections": + var body struct { + Name string `json:"name"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + if body.Name != colAppSettings { + t.Errorf("created collection %q, want %q", body.Name, colAppSettings) + } + f.createCalls++ + f.created = true + writeTestJSON(w, http.StatusOK, map[string]any{"id": "col_app"}) + + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + http.Error(w, "unexpected", http.StatusInternalServerError) + } + })) + t.Cleanup(srv.Close) + return srv +} + +func writeTestJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func newSchemaTestServer(t *testing.T, f *fakeSchemaPB) *Server { + t.Helper() + pbSrv := f.server(t) + // PluginsFile empty: no legacy import in play here. + return New(config.Config{UsersCollection: "users", PluginsFile: ""}, + pb.New(pbSrv.URL, "admin@test.local", "pw")) +} + +// The guard: a missing settings collection is created rather than retried +// forever, so a stack upgraded with the bootstrap off still comes up. +func TestLoadPluginsCreatesMissingSettingsCollection(t *testing.T) { + f := &fakeSchemaPB{} + s := newSchemaTestServer(t, f) + + if err := s.loadPlugins(context.Background()); err != nil { + t.Fatalf("loadPlugins should recover by creating the collection, got %v", err) + } + if !s.plugins.Ready() { + t.Fatal("plugins should be loaded after the collection was created") + } + + f.mu.Lock() + calls := f.createCalls + f.mu.Unlock() + if calls != 1 { + t.Fatalf("expected the collection to be created once, got %d", calls) + } +} + +// Once the collection exists, nothing is created again. +func TestLoadPluginsDoesNotRecreateExistingCollection(t *testing.T) { + f := &fakeSchemaPB{created: true} + s := newSchemaTestServer(t, f) + + if err := s.loadPlugins(context.Background()); err != nil { + t.Fatalf("loadPlugins: %v", err) + } + f.mu.Lock() + calls := f.createCalls + f.mu.Unlock() + if calls != 0 { + t.Fatalf("an existing collection must not be recreated, got %d creates", calls) + } +} + +// A database that is merely unreachable must NOT trigger schema surgery: the +// remedy there is to wait, and creating collections against a flaky database is +// exactly the wrong reflex. +func TestLoadPluginsDoesNotCreateOnOutage(t *testing.T) { + f := &fakeSchemaPB{created: true, unreachable: true} + s := newSchemaTestServer(t, f) + + if err := s.loadPlugins(context.Background()); err == nil { + t.Fatal("expected an error while the database is unreachable") + } + if s.plugins.Ready() { + t.Fatal("plugins must not report ready after an outage") + } + f.mu.Lock() + calls := f.createCalls + f.mu.Unlock() + if calls != 0 { + t.Fatalf("an outage must not create collections, got %d creates", calls) + } +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 4567112..52fa788 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -127,6 +127,7 @@ import ( "sync" "time" + "drivervault/apiserver/internal/bootstrap" "drivervault/apiserver/internal/config" "drivervault/apiserver/internal/ocpp" "drivervault/apiserver/internal/pb" @@ -222,9 +223,34 @@ func (s *Server) StartPlugins() error { return err } -// loadPlugins imports a pre-PocketBase plugins.json if one is still lying around -// and the database holds no settings yet, then reads the settings. +// loadPlugins reads the settings, creating the collection they live in if it +// turns out not to exist. +// +// That happens on a stack upgraded with PB_BOOTSTRAP off: the on-boot schema +// pass never ran, so app_settings was never created, and because a missing +// collection is read as "not ready" (never as "no plugins configured", which +// would let the first save overwrite settings the server merely failed to find) +// the retry below would spin forever with the plugin panel stuck at 503. So +// create just that one collection — not a full schema reconcile, which an +// operator who turned the bootstrap off has not asked for — and read again. func (s *Server) loadPlugins(ctx context.Context) error { + err := s.readPlugins(ctx) + if err == nil || !plugins.IsMissingCollection(err) { + return err + } + + log.Printf("plugins: the %s collection does not exist; creating it", colAppSettings) + if repairErr := bootstrap.EnsureCollection(ctx, s.pb, colAppSettings); repairErr != nil { + log.Printf("plugins: could not create %s: %v", colAppSettings, repairErr) + return err // report the original problem, not the repair's + } + log.Printf("plugins: created %s", colAppSettings) + return s.readPlugins(ctx) +} + +// readPlugins imports a pre-PocketBase plugins.json if one is still lying around +// and the database holds no settings yet, then reads the settings. +func (s *Server) readPlugins(ctx context.Context) error { switch migrated, err := plugins.MigrateLegacyFile(ctx, s.pluginStore, s.legacyPlugins); { case err != nil: // Not fatal: the read below reports the real problem if there is one. diff --git a/API Server/internal/bootstrap/bootstrap.go b/API Server/internal/bootstrap/bootstrap.go index a962d02..20cab31 100644 --- a/API Server/internal/bootstrap/bootstrap.go +++ b/API Server/internal/bootstrap/bootstrap.go @@ -193,6 +193,43 @@ func Run(ctx context.Context, client *pb.Client, opts Options) error { return nil } +// EnsureCollection creates one collection from the desired schema if it is not +// already present, and does nothing otherwise. +// +// It is deliberately narrower than Run: no field reconcile on other +// collections, no super-admin. That makes it safe to call on a deployment that +// turned the full bootstrap off, which is the case it exists for — a collection +// the server cannot run without (app_settings, holding the plugin settings) is +// missing, and no amount of retrying a read will conjure it. +func EnsureCollection(ctx context.Context, client *pb.Client, name string) error { + if _, known := collectionsSchema[name]; !known { + return fmt.Errorf("unknown collection %q", name) + } + if err := client.Authenticate(ctx); err != nil { + return fmt.Errorf("authenticate: %w", err) + } + existing, err := listCollections(ctx, client) + if err != nil { + return fmt.Errorf("list collections: %w", err) + } + + idByName := make(map[string]string, len(existing)) + present := false + for _, c := range existing { + idByName[c.Name] = c.ID + if c.Name == name { + present = true + } + } + if present { + return nil + } + if err := createCollection(ctx, client, name, detectFormat(existing), idByName); err != nil { + return fmt.Errorf("create %s: %w", name, err) + } + return nil +} + // --- PocketBase collection calls ------------------------------------------- type collectionMeta struct { diff --git a/API Server/internal/plugins/README.md b/API Server/internal/plugins/README.md index 5e7422a..4927628 100644 --- a/API Server/internal/plugins/README.md +++ b/API Server/internal/plugins/README.md @@ -263,6 +263,11 @@ it runs as its own process/container, an external plugin is also the - **Upgrading from a pre-PocketBase install**: an existing `plugins.json` (see `PLUGINS_FILE`) is imported into the database on the first boot that finds no settings there, and renamed to `plugins.json.migrated`. +- **If the `app_settings` collection is missing** — an upgrade on a stack that + runs with `PB_BOOTSTRAP=false`, so the on-boot schema pass never created it — + the server creates that one collection itself and reads again. A missing + collection is told apart from a database that is merely unreachable, because + the remedy differs: creating collections is the wrong reflex during an outage. - **Secrets** (`Secret: true` fields) are returned masked. On save, a field still equal to the mask keeps its stored value; send a new value to change it, or an empty string to clear it. diff --git a/API Server/internal/plugins/manager.go b/API Server/internal/plugins/manager.go index 28b4c04..e4e6d26 100644 --- a/API Server/internal/plugins/manager.go +++ b/API Server/internal/plugins/manager.go @@ -576,6 +576,12 @@ var ( errUnknown = errors.New("unknown plugin") errPersist = errors.New("plugin settings could not be saved") errNotReady = errors.New("plugin settings are not loaded yet") + + // errNoCollection is a not-ready that waiting alone will never resolve: the + // collection the settings live in does not exist. It wraps errNotReady, so + // the safety behaviour (refuse every write) is unchanged; the extra tag only + // lets the caller fix the cause instead of retrying forever. + errNoCollection = fmt.Errorf("%w: the settings collection does not exist", errNotReady) ) // IsUnknown reports whether err came from addressing a plugin that doesn't exist. @@ -590,3 +596,8 @@ func IsPersist(err error) bool { return errors.Is(err, errPersist) } // the store was unreachable at boot and is still being retried. Callers should // answer 503 rather than present the plugin list as empty. func IsNotReady(err error) bool { return errors.Is(err, errNotReady) } + +// IsMissingCollection reports whether err means the settings collection does not +// exist. It implies IsNotReady, and narrows it: retrying cannot help, so the +// caller should create the collection and read again. +func IsMissingCollection(err error) bool { return errors.Is(err, errNoCollection) } diff --git a/API Server/internal/plugins/store.go b/API Server/internal/plugins/store.go index b826549..9f69879 100644 --- a/API Server/internal/plugins/store.go +++ b/API Server/internal/plugins/store.go @@ -103,8 +103,15 @@ func (s *pbStore) find(ctx context.Context) (settingsRecord, bool, error) { res, err := s.client.List(ctx, s.collection, q) if err != nil { - // A missing collection means bootstrap has not run yet — still "not - // ready" rather than "no settings", so nothing gets overwritten. + if isNotFound(err) { + // A 404 from a list means the collection itself is absent: listing an + // existing but empty collection answers 200 with no items. Tagged + // apart from a plain outage because retrying a read against a + // collection that does not exist can never succeed — the caller has + // to create it. Still a flavour of not-ready, so nothing is + // overwritten in the meantime. + return settingsRecord{}, false, fmt.Errorf("%w: %v", errNoCollection, err) + } return settingsRecord{}, false, fmt.Errorf("%w: %v", errNotReady, err) } var items []settingsRecord diff --git a/API Server/internal/plugins/store_pb_test.go b/API Server/internal/plugins/store_pb_test.go index 05bf2f2..ff95fcd 100644 --- a/API Server/internal/plugins/store_pb_test.go +++ b/API Server/internal/plugins/store_pb_test.go @@ -192,17 +192,35 @@ func TestPBStoreUnreachableStaysUnloaded(t *testing.T) { } // A missing collection (bootstrap has not run) is "not ready", never "empty" — -// otherwise the first save would write a fresh document over nothing. +// otherwise the first save would write a fresh document over nothing. It is also +// tagged separately, because retrying alone can never resolve it. func TestPBStoreMissingCollectionIsNotReady(t *testing.T) { m := NewManager(newPBStore(t, &fakePB{missingCol: true})) - if err := m.Load(context.Background()); !IsNotReady(err) { + err := m.Load(context.Background()) + if !IsNotReady(err) { t.Fatalf("expected not-ready for a missing collection, got %v", err) } + if !IsMissingCollection(err) { + t.Fatalf("a missing collection must be distinguishable, got %v", err) + } if m.Ready() { t.Fatal("manager must not be ready without its collection") } } +// A plain outage must NOT look like a missing collection: creating the +// collection is not the remedy for a database that is merely unreachable. +func TestPBStoreOutageIsNotAMissingCollection(t *testing.T) { + m := NewManager(newPBStore(t, &fakePB{listErr: true})) + err := m.Load(context.Background()) + if !IsNotReady(err) { + t.Fatalf("expected not-ready, got %v", err) + } + if IsMissingCollection(err) { + t.Fatalf("an outage must not be reported as a missing collection: %v", err) + } +} + // The legacy file is imported into PocketBase exactly once. func TestPBStoreLegacyImport(t *testing.T) { f := &fakePB{} diff --git a/Docker-AIO/.env.example b/Docker-AIO/.env.example index 814fe98..1495752 100644 --- a/Docker-AIO/.env.example +++ b/Docker-AIO/.env.example @@ -12,10 +12,11 @@ DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password DRIVERVAULT_SUPERADMIN_NAME=Administrator -# Schema creation/reconcile on boot. Leave this true: a release can add a -# collection the server needs (app_settings, which holds the plugin settings), -# and a stack that skipped the bootstrap never gets it — the plugin panel then -# answers 503 forever. Set false only for a database you know matches the release. +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. PB_BOOTSTRAP=true # Allowed CORS origin(s) — match your web origin / WEB_PORT. diff --git a/Docker-AIO/.env.prod.example b/Docker-AIO/.env.prod.example index a3b637a..0c8e25e 100644 --- a/Docker-AIO/.env.prod.example +++ b/Docker-AIO/.env.prod.example @@ -18,10 +18,11 @@ DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password DRIVERVAULT_SUPERADMIN_NAME=Administrator -# Schema creation/reconcile on boot. Leave this true: a release can add a -# collection the server needs (app_settings, which holds the plugin settings), -# and a stack that skipped the bootstrap never gets it — the plugin panel then -# answers 503 forever. Set false only for a database you know matches the release. +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. PB_BOOTSTRAP=true # Allowed CORS origin(s) — match your public web URL / WEB_PORT. diff --git a/Docker-AIO/Dockerfile b/Docker-AIO/Dockerfile index 0e59897..e31de97 100644 --- a/Docker-AIO/Dockerfile +++ b/Docker-AIO/Dockerfile @@ -220,9 +220,9 @@ ENV API_ADDR=:8080 \ # Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD. # Optional: DRIVERVAULT_SUPERADMIN_EMAIL / DRIVERVAULT_SUPERADMIN_PASSWORD create # the first app super-admin on boot. PB_BOOTSTRAP=false skips schema setup — -# leave it on: a release can add a collection the server needs (app_settings, -# which holds the plugin settings), and a stack that skipped the bootstrap never -# gets it, leaving the plugin panel answering 503 indefinitely. +# leave it on: a release can add collections or fields the server needs, and a +# stack that skips the bootstrap never gets them. (app_settings, which holds the +# plugin settings, is created on demand; nothing else is.) # For Anker Solix charger control, OCPP_REQUIRE_TLS (default true) rejects # chargers that did not arrive over TLS — this image serves plain HTTP, so put a # TLS-terminating proxy in front and set OCPP_PUBLIC_URL to the public wss:// diff --git a/Docker-AIO/README.md b/Docker-AIO/README.md index 83dfaae..e3085da 100644 --- a/Docker-AIO/README.md +++ b/Docker-AIO/README.md @@ -64,12 +64,13 @@ Identical to the multi-container stack, and idempotent: missing collections, reconciles existing ones, and creates the first app `superadmin` from `DRIVERVAULT_SUPERADMIN_EMAIL` / `_PASSWORD`. -> Leave `PB_BOOTSTRAP` at `true`, including across upgrades. A release can add a -> collection the server needs — `app_settings`, which holds the plugin settings, -> is one — and a stack that skipped the bootstrap never gets it. The plugin panel -> then answers `503` indefinitely, because a missing collection is read as "the -> database is not ready yet", never as "no plugins configured". Turn it off only -> for a database you know already matches the release you are running. +> Leave `PB_BOOTSTRAP` at `true`, including across upgrades. A release can add +> collections or fields the server needs, and a stack that skipped the bootstrap +> never gets them. The API Server self-heals exactly one thing — `app_settings`, +> the collection holding the plugin settings, which it creates on demand because +> it cannot serve the plugin panel without it. Every other schema change still +> depends on this flag, so turn it off only for a database you know already +> matches the release you are running. ## Volumes diff --git a/Docker-AIO/docker-compose.prod.yml b/Docker-AIO/docker-compose.prod.yml index 4f7ff72..dac6fd1 100644 --- a/Docker-AIO/docker-compose.prod.yml +++ b/Docker-AIO/docker-compose.prod.yml @@ -26,12 +26,13 @@ services: # Probed by the panel status page. nginx serves the Web App on port 80 # inside this container, so the default (localhost:8090) would never answer. WEBAPP_URL: "http://127.0.0.1:80" - # Schema + super-admin bootstrap (idempotent). Leave this ON. A release can - # add a collection the server needs — app_settings, holding the plugin - # settings, is one — and a stack that skipped the bootstrap never gets it: - # the plugin panel then answers 503 forever, because a missing collection - # is read as "database not ready", never as "no plugins configured". - # Turn it off only for a database you know already matches the release. + # Schema + super-admin bootstrap (idempotent). Leave this ON: a release can + # add collections or fields the server needs, and a stack that skips the + # bootstrap never gets them. The API Server self-heals exactly one thing — + # app_settings, the collection holding the plugin settings, which it + # creates on demand because it cannot serve the plugin panel without it. + # Every other schema change still depends on this flag. Turn it off only + # for a database you know already matches the release. PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" diff --git a/Docker-AIO/docker-compose.yml b/Docker-AIO/docker-compose.yml index 87ab8e0..17d6c54 100644 --- a/Docker-AIO/docker-compose.yml +++ b/Docker-AIO/docker-compose.yml @@ -29,12 +29,13 @@ services: # Probed by the panel status page. nginx serves the Web App on port 80 # inside this container, so the default (localhost:8090) would never answer. WEBAPP_URL: "http://127.0.0.1:80" - # Schema + super-admin bootstrap (idempotent). Leave this ON. A release can - # add a collection the server needs — app_settings, holding the plugin - # settings, is one — and a stack that skipped the bootstrap never gets it: - # the plugin panel then answers 503 forever, because a missing collection - # is read as "database not ready", never as "no plugins configured". - # Turn it off only for a database you know already matches the release. + # Schema + super-admin bootstrap (idempotent). Leave this ON: a release can + # add collections or fields the server needs, and a stack that skips the + # bootstrap never gets them. The API Server self-heals exactly one thing — + # app_settings, the collection holding the plugin settings, which it + # creates on demand because it cannot serve the plugin panel without it. + # Every other schema change still depends on this flag. Turn it off only + # for a database you know already matches the release. PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" diff --git a/Docker/.env.example b/Docker/.env.example index 87de3f7..5a3db4b 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -12,10 +12,11 @@ DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password DRIVERVAULT_SUPERADMIN_NAME=Administrator -# Schema creation/reconcile on boot. Leave this true: a release can add a -# collection the server needs (app_settings, which holds the plugin settings), -# and a stack that skipped the bootstrap never gets it — the plugin panel then -# answers 503 forever. Set false only for a database you know matches the release. +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. PB_BOOTSTRAP=true # --- API Server ------------------------------------------------------------- diff --git a/Docker/.env.prod.example b/Docker/.env.prod.example index b6b5b13..0565bfd 100644 --- a/Docker/.env.prod.example +++ b/Docker/.env.prod.example @@ -24,10 +24,11 @@ DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password DRIVERVAULT_SUPERADMIN_NAME=Administrator -# Schema creation/reconcile on boot. Leave this true: a release can add a -# collection the server needs (app_settings, which holds the plugin settings), -# and a stack that skipped the bootstrap never gets it — the plugin panel then -# answers 503 forever. Set false only for a database you know matches the release. +# Schema creation/reconcile on boot. Leave this true: a release can add +# collections or fields the server needs, and a stack that skips the bootstrap +# never gets them. (The API Server creates app_settings, which holds the plugin +# settings, on demand — but only that one.) Set false only for a database you +# know already matches the release. PB_BOOTSTRAP=true # --- API Server -------------------------------------------------------------- diff --git a/Docker/README.md b/Docker/README.md index 0d4782e..7b26125 100644 --- a/Docker/README.md +++ b/Docker/README.md @@ -49,12 +49,13 @@ Both steps are idempotent, so restarts and upgrades are safe: ones, then creates the first app `superadmin` from `DRIVERVAULT_SUPERADMIN_EMAIL` / `_PASSWORD` if no such user exists. -> Leave `PB_BOOTSTRAP` at `true`, including across upgrades. A release can add a -> collection the server needs — `app_settings`, which holds the plugin settings, -> is one — and a stack that skipped the bootstrap never gets it. The plugin panel -> then answers `503` indefinitely, because a missing collection is read as "the -> database is not ready yet", never as "no plugins configured". Turn it off only -> for a database you know already matches the release you are running. +> Leave `PB_BOOTSTRAP` at `true`, including across upgrades. A release can add +> collections or fields the server needs, and a stack that skipped the bootstrap +> never gets them. The API Server self-heals exactly one thing — `app_settings`, +> the collection holding the plugin settings, which it creates on demand because +> it cannot serve the plugin panel without it. Every other schema change still +> depends on this flag, so turn it off only for a database you know already +> matches the release you are running. No manual `setup-pocketbase.mjs` step is needed here — the server runs the same schema reconcile itself. diff --git a/Docker/docker-compose.prod.yml b/Docker/docker-compose.prod.yml index ddf9a00..d9c09f5 100644 --- a/Docker/docker-compose.prod.yml +++ b/Docker/docker-compose.prod.yml @@ -59,12 +59,13 @@ services: WEBAPP_URL: "http://web-app:8090" CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}" AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}" - # Schema + super-admin bootstrap (idempotent). Leave this ON. A release can - # add a collection the server needs — app_settings, holding the plugin - # settings, is one — and a stack that skipped the bootstrap never gets it: - # the plugin panel then answers 503 forever, because a missing collection - # is read as "database not ready", never as "no plugins configured". - # Turn it off only for a database you know already matches the release. + # Schema + super-admin bootstrap (idempotent). Leave this ON: a release can + # add collections or fields the server needs, and a stack that skips the + # bootstrap never gets them. The API Server self-heals exactly one thing — + # app_settings, the collection holding the plugin settings, which it + # creates on demand because it cannot serve the plugin panel without it. + # Every other schema change still depends on this flag. Turn it off only + # for a database you know already matches the release. PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}" diff --git a/Docker/docker-compose.yml b/Docker/docker-compose.yml index c837b78..f629313 100644 --- a/Docker/docker-compose.yml +++ b/Docker/docker-compose.yml @@ -54,12 +54,12 @@ services: # the collections are still created but no app user is, leaving a stack # you cannot log into. # - # Leave the bootstrap ON. A release can add a collection the server needs — - # app_settings, holding the plugin settings, is one — and a stack that - # skipped it never gets that collection: the plugin panel then answers 503 - # forever, because a missing collection is read as "database not ready", - # never as "no plugins configured". Turn it off only for a database you - # know already matches the release. + # Leave the bootstrap ON: a release can add collections or fields the + # server needs, and a stack that skips it never gets them. The API Server + # self-heals exactly one thing — app_settings, the collection holding the + # plugin settings, which it creates on demand because it cannot serve the + # plugin panel without it. Every other schema change still depends on this + # flag. Turn it off only for a database you know matches the release. PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}" DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}" DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"