01a8fecfixed the advice that led operators into this, but advice is not a guard: a stack still running PB_BOOTSTRAP=false gets no app_settings collection on upgrade, and the plugin panel sits at 503 while the retry loop reads a collection that does not exist. The fix is not to soften the reading. A missing collection stays "not ready" rather than "no plugins configured", because the alternative lets the first save write a fresh document over settings the server merely failed to find - the failure this whole line of work exists to prevent. Instead the server now fixes the cause: on a missing collection it creates that collection and reads again. Three pieces: bootstrap.EnsureCollection creates one named collection from the desired schema if absent, and nothing else. Deliberately narrower than Run - no field reconcile elsewhere, no super-admin - so it is safe to call on a deployment that turned the full bootstrap off. It creates the collection the server cannot start without, not the schema the operator declined. The store tells a missing collection apart from an outage. A 404 from a list means the collection itself is gone: an existing but empty one answers 200 with no items. That is tagged errNoCollection, which wraps errNotReady so every write is still refused, and IsMissingCollection narrows it. The distinction matters because the remedies are opposites - creating collections against a flaky database is exactly the wrong reflex, and a test pins that an outage does not trigger it. loadPlugins acts on the tag once, then re-reads. Failing to create is reported as the original read error rather than the repair's, so the log names the real problem. Six tests: the tag and its negative in internal/plugins, and three in internal/api against a fake PocketBase covering the collection being created exactly once, an existing collection not being recreated, and an outage creating nothing. Docs from01a8fecare corrected in the same pass - they said the panel would answer 503 forever, which is no longer true. They now say what still depends on the bootstrap (every other collection and field) and what does not (app_settings alone). go build, go vet and go test ./... pass; compose files still parse. Not verified: no Docker CLI here, so the repair has not been exercised against a real PocketBase, only the fake. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
463 lines
14 KiB
Go
463 lines
14 KiB
Go
// Package bootstrap brings a fresh PocketBase up to the schema the API Server
|
|
// expects, on server startup. It is the Go port of scripts/setup-pocketbase.mjs
|
|
// and is idempotent: existing collections are reconciled (missing fields added,
|
|
// relation/select options fixed) rather than recreated, and an already-present
|
|
// super-admin user is left untouched.
|
|
//
|
|
// It creates the app-level collections and, optionally, the DriverVault
|
|
// super-admin account. It does NOT create the PocketBase *superuser* — that is a
|
|
// chicken-and-egg the REST API can't solve (creating a _superusers record needs
|
|
// an existing superuser token), so the PocketBase container upserts it from env
|
|
// on boot instead. Bootstrap authenticates with that same superuser.
|
|
package bootstrap
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
|
|
"drivervault/apiserver/internal/pb"
|
|
)
|
|
|
|
// Options configures a bootstrap run. SuperAdmin* are optional: when either the
|
|
// email or password is empty, the super-admin step is skipped.
|
|
type Options struct {
|
|
UsersCollection string
|
|
SuperAdminEmail string
|
|
SuperAdminPassword string
|
|
SuperAdminName string
|
|
}
|
|
|
|
// fieldDef is a schema field normalized to a single shape; it is rendered into
|
|
// the right PocketBase wire format ("fields" for v0.23+, legacy "schema") per
|
|
// the detected server version.
|
|
type fieldDef struct {
|
|
name string
|
|
typ string
|
|
required bool
|
|
relTo string // relation target collection name
|
|
cascadeDelete bool // relations only; default true
|
|
values []string
|
|
onCreate bool // autodate only
|
|
onUpdate bool // autodate only
|
|
maxSize int // file/json only, bytes
|
|
mimeTypes []string
|
|
}
|
|
|
|
// Field builders, mirroring the F.* helpers in setup-pocketbase.mjs.
|
|
func fText(name string, required bool) fieldDef {
|
|
return fieldDef{name: name, typ: "text", required: required}
|
|
}
|
|
func fNumber(name string) fieldDef { return fieldDef{name: name, typ: "number"} }
|
|
func fBool(name string) fieldDef { return fieldDef{name: name, typ: "bool"} }
|
|
func fDate(name string, required bool) fieldDef {
|
|
return fieldDef{name: name, typ: "date", required: required}
|
|
}
|
|
func fRelation(name, relTo string, required, cascade bool) fieldDef {
|
|
return fieldDef{name: name, typ: "relation", required: required, relTo: relTo, cascadeDelete: cascade}
|
|
}
|
|
func fSelect(name string, values []string, required bool) fieldDef {
|
|
return fieldDef{name: name, typ: "select", required: required, values: values}
|
|
}
|
|
func fAutodate(name string, onCreate, onUpdate bool) fieldDef {
|
|
return fieldDef{name: name, typ: "autodate", onCreate: onCreate, onUpdate: onUpdate}
|
|
}
|
|
func fFile(name string, maxSize int, mimeTypes []string) fieldDef {
|
|
return fieldDef{name: name, typ: "file", maxSize: maxSize, mimeTypes: mimeTypes}
|
|
}
|
|
func fJSON(name string, maxSize int) fieldDef {
|
|
return fieldDef{name: name, typ: "json", maxSize: maxSize}
|
|
}
|
|
|
|
// attachment is the single optional file a record can carry (scan/receipt/photo).
|
|
// The 10MB cap matches maxAttachmentUpload in the API server.
|
|
func attachment() fieldDef {
|
|
return fFile("file", 10485760, []string{
|
|
"application/pdf", "image/jpeg", "image/png", "image/webp", "image/heic",
|
|
})
|
|
}
|
|
|
|
// renderField turns a fieldDef into the map PocketBase expects, in either the
|
|
// modern ("fields") or legacy ("schema") layout. idByName resolves a relation's
|
|
// target collection name to its id.
|
|
func renderField(d fieldDef, format string, idByName map[string]string) map[string]any {
|
|
if format == "schema" {
|
|
options := map[string]any{}
|
|
switch d.typ {
|
|
case "relation":
|
|
options["collectionId"] = idByName[d.relTo]
|
|
options["cascadeDelete"] = d.cascadeDelete
|
|
options["maxSelect"] = 1
|
|
options["minSelect"] = 0
|
|
case "select":
|
|
options["values"] = d.values
|
|
options["maxSelect"] = 1
|
|
case "file":
|
|
options["maxSelect"] = 1
|
|
options["maxSize"] = d.maxSize
|
|
options["mimeTypes"] = mimeOrEmpty(d.mimeTypes)
|
|
case "json":
|
|
options["maxSize"] = d.maxSize
|
|
}
|
|
return map[string]any{"name": d.name, "type": d.typ, "required": d.required, "options": options}
|
|
}
|
|
|
|
field := map[string]any{"name": d.name, "type": d.typ, "required": d.required}
|
|
switch d.typ {
|
|
case "relation":
|
|
field["collectionId"] = idByName[d.relTo]
|
|
field["cascadeDelete"] = d.cascadeDelete
|
|
field["maxSelect"] = 1
|
|
field["minSelect"] = 0
|
|
case "select":
|
|
field["values"] = d.values
|
|
field["maxSelect"] = 1
|
|
case "autodate":
|
|
field["onCreate"] = d.onCreate
|
|
field["onUpdate"] = d.onUpdate
|
|
case "file":
|
|
field["maxSelect"] = 1
|
|
field["maxSize"] = d.maxSize
|
|
field["mimeTypes"] = mimeOrEmpty(d.mimeTypes)
|
|
case "json":
|
|
field["maxSize"] = d.maxSize
|
|
}
|
|
return field
|
|
}
|
|
|
|
func mimeOrEmpty(m []string) []string {
|
|
if m == nil {
|
|
return []string{}
|
|
}
|
|
return m
|
|
}
|
|
|
|
// Run authenticates as the superuser, creates any missing collections, reconciles
|
|
// existing ones, and (when configured) ensures the DriverVault super-admin exists.
|
|
// It is safe to call on every startup.
|
|
func Run(ctx context.Context, client *pb.Client, opts Options) error {
|
|
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)
|
|
}
|
|
format := detectFormat(existing)
|
|
log.Printf("bootstrap: schema format %q", format)
|
|
|
|
present := make(map[string]bool, len(existing))
|
|
idByName := make(map[string]string, len(existing))
|
|
for _, c := range existing {
|
|
present[c.Name] = true
|
|
idByName[c.Name] = c.ID
|
|
}
|
|
|
|
// Create in dependency order: organizations before users references it; cars
|
|
// before its relations. "users" is PocketBase's built-in auth collection and
|
|
// is never created here — only reconciled below.
|
|
for _, name := range createOrder {
|
|
if present[name] {
|
|
continue
|
|
}
|
|
if err := createCollection(ctx, client, name, format, idByName); err != nil {
|
|
return fmt.Errorf("create %s: %w", name, err)
|
|
}
|
|
log.Printf("bootstrap: ✓ %s created", name)
|
|
// Refresh so later relations resolve newly-created collection ids.
|
|
refreshed, err := listCollections(ctx, client)
|
|
if err != nil {
|
|
return fmt.Errorf("re-list collections: %w", err)
|
|
}
|
|
for _, c := range refreshed {
|
|
present[c.Name] = true
|
|
idByName[c.Name] = c.ID
|
|
}
|
|
}
|
|
|
|
// Reconcile fields on existing collections (add missing + fix relation
|
|
// cascade and select value lists).
|
|
for _, name := range reconcileOrder {
|
|
if err := reconcileFields(ctx, client, name, format, idByName); err != nil {
|
|
return fmt.Errorf("reconcile %s: %w", name, err)
|
|
}
|
|
}
|
|
|
|
if err := ensureSuperAdmin(ctx, client, opts); err != nil {
|
|
return fmt.Errorf("super-admin: %w", err)
|
|
}
|
|
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 {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Fields []map[string]any `json:"fields"`
|
|
Schema []map[string]any `json:"schema"`
|
|
}
|
|
|
|
func listCollections(ctx context.Context, client *pb.Client) ([]collectionMeta, error) {
|
|
raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections?perPage=200", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return nil, fmt.Errorf("status %d: %s", status, raw)
|
|
}
|
|
var env struct {
|
|
Items []collectionMeta `json:"items"`
|
|
}
|
|
if err := json.Unmarshal(raw, &env); err != nil {
|
|
return nil, err
|
|
}
|
|
return env.Items, nil
|
|
}
|
|
|
|
// detectFormat reports whether this PocketBase serializes fields under "fields"
|
|
// (v0.23+) or the legacy "schema" key, defaulting to the modern format.
|
|
func detectFormat(cols []collectionMeta) string {
|
|
for _, c := range cols {
|
|
if len(c.Fields) > 0 {
|
|
return "fields"
|
|
}
|
|
if len(c.Schema) > 0 {
|
|
return "schema"
|
|
}
|
|
}
|
|
return "fields"
|
|
}
|
|
|
|
func createCollection(ctx context.Context, client *pb.Client, name, format string, idByName map[string]string) error {
|
|
rendered := make([]map[string]any, 0, len(collectionsSchema[name]))
|
|
for _, d := range collectionsSchema[name] {
|
|
rendered = append(rendered, renderField(d, format, idByName))
|
|
}
|
|
body := map[string]any{
|
|
"name": name,
|
|
"type": "base",
|
|
format: rendered, // "fields" or "schema"
|
|
// Rules left null => superuser-only access (the API Server is the only client).
|
|
"listRule": nil,
|
|
"viewRule": nil,
|
|
"createRule": nil,
|
|
"updateRule": nil,
|
|
"deleteRule": nil,
|
|
}
|
|
if idx := indexes[name]; len(idx) > 0 {
|
|
body["indexes"] = idx
|
|
}
|
|
raw, status, err := client.Raw(ctx, http.MethodPost, "/api/collections", body)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("status %d: %s", status, raw)
|
|
}
|
|
var created struct {
|
|
ID string `json:"id"`
|
|
}
|
|
if err := json.Unmarshal(raw, &created); err == nil {
|
|
idByName[name] = created.ID
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// reconcileFields brings an existing collection in line with the desired schema:
|
|
// it appends missing fields and updates relation cascadeDelete and select value
|
|
// lists on existing fields, preserving every existing field (including system
|
|
// fields) and their ids.
|
|
func reconcileFields(ctx context.Context, client *pb.Client, name, format string, idByName map[string]string) error {
|
|
raw, status, err := client.Raw(ctx, http.MethodGet, "/api/collections/"+name, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("get: status %d: %s", status, raw)
|
|
}
|
|
var col collectionMeta
|
|
if err := json.Unmarshal(raw, &col); err != nil {
|
|
return err
|
|
}
|
|
current := col.Fields
|
|
if format == "schema" {
|
|
current = col.Schema
|
|
}
|
|
|
|
defs := collectionsSchema[name]
|
|
defByName := make(map[string]fieldDef, len(defs))
|
|
for _, d := range defs {
|
|
defByName[d.name] = d
|
|
}
|
|
|
|
var changes []string
|
|
merged := make([]map[string]any, 0, len(current)+len(defs))
|
|
haveName := make(map[string]bool, len(current))
|
|
for _, f := range current {
|
|
fname, _ := f["name"].(string)
|
|
haveName[fname] = true
|
|
if def, ok := defByName[fname]; ok {
|
|
switch def.typ {
|
|
case "relation":
|
|
if asBool(f["cascadeDelete"]) != def.cascadeDelete {
|
|
f["cascadeDelete"] = def.cascadeDelete
|
|
changes = append(changes, fname+".cascadeDelete")
|
|
}
|
|
case "select":
|
|
if !sameValues(f["values"], def.values) {
|
|
f["values"] = def.values
|
|
changes = append(changes, fname+".values")
|
|
}
|
|
}
|
|
}
|
|
merged = append(merged, f)
|
|
}
|
|
// Append missing fields.
|
|
for _, d := range defs {
|
|
if !haveName[d.name] {
|
|
merged = append(merged, renderField(d, format, idByName))
|
|
changes = append(changes, "+"+d.name)
|
|
}
|
|
}
|
|
|
|
if len(changes) == 0 {
|
|
log.Printf("bootstrap: • %s up to date", name)
|
|
return nil
|
|
}
|
|
patch := map[string]any{format: merged}
|
|
raw, status, err = client.Raw(ctx, http.MethodPatch, "/api/collections/"+col.ID, patch)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("patch: status %d: %s", status, raw)
|
|
}
|
|
log.Printf("bootstrap: ✓ %s — %v", name, changes)
|
|
return nil
|
|
}
|
|
|
|
// ensureSuperAdmin creates the DriverVault super-admin user when it does not yet
|
|
// exist. An already-present account (matched by email) is left untouched.
|
|
func ensureSuperAdmin(ctx context.Context, client *pb.Client, opts Options) error {
|
|
if opts.SuperAdminEmail == "" || opts.SuperAdminPassword == "" {
|
|
return nil // not requested
|
|
}
|
|
coll := opts.UsersCollection
|
|
if coll == "" {
|
|
coll = "users"
|
|
}
|
|
|
|
filter := url.QueryEscape(fmt.Sprintf("email='%s'", opts.SuperAdminEmail))
|
|
raw, status, err := client.Raw(ctx, http.MethodGet,
|
|
"/api/collections/"+coll+"/records?perPage=1&filter="+filter, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("lookup: status %d: %s", status, raw)
|
|
}
|
|
var found struct {
|
|
TotalItems int `json:"totalItems"`
|
|
}
|
|
if err := json.Unmarshal(raw, &found); err != nil {
|
|
return err
|
|
}
|
|
if found.TotalItems > 0 {
|
|
log.Printf("bootstrap: • super-admin %s already exists", opts.SuperAdminEmail)
|
|
return nil
|
|
}
|
|
|
|
name := opts.SuperAdminName
|
|
if name == "" {
|
|
name = "Administrator"
|
|
}
|
|
create := map[string]any{
|
|
"email": opts.SuperAdminEmail,
|
|
"password": opts.SuperAdminPassword,
|
|
"passwordConfirm": opts.SuperAdminPassword,
|
|
"name": name,
|
|
"role": "superadmin",
|
|
"verified": true,
|
|
"emailVisibility": false,
|
|
}
|
|
raw, status, err = client.Raw(ctx, http.MethodPost, "/api/collections/"+coll+"/records", create)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("create: status %d: %s", status, raw)
|
|
}
|
|
log.Printf("bootstrap: ✓ super-admin %s created", opts.SuperAdminEmail)
|
|
return nil
|
|
}
|
|
|
|
// --- helpers ---------------------------------------------------------------
|
|
|
|
func asBool(v any) bool {
|
|
b, _ := v.(bool)
|
|
return b
|
|
}
|
|
|
|
// sameValues reports whether a select field's current values equal the desired
|
|
// set (order-insensitive), matching the reconcile check in setup-pocketbase.mjs.
|
|
func sameValues(current any, want []string) bool {
|
|
arr, ok := current.([]any)
|
|
if !ok || len(arr) != len(want) {
|
|
return false
|
|
}
|
|
have := make(map[string]bool, len(arr))
|
|
for _, v := range arr {
|
|
if s, ok := v.(string); ok {
|
|
have[s] = true
|
|
}
|
|
}
|
|
for _, w := range want {
|
|
if !have[w] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|