Files
DriverVault/API Server/internal/bootstrap/bootstrap.go
T
tajniak81andClaude Opus 4.8 30c9cdebe9 Add production Docker compose + on-startup PocketBase bootstrap
Introduce registry-pull production stacks (docker-compose.prod.yml) for
both the multi-container Docker setup and the all-in-one Docker AIO image,
with everything an operator needs (superuser, super-admin, ports, volumes)
driven from .env.

The API Server now bootstraps PocketBase on startup: a new internal/bootstrap
package (Go port of setup-pocketbase.mjs) creates missing collections,
reconciles existing ones, and creates the DriverVault super-admin from
DRIVERVAULT_SUPERADMIN_* when absent. Idempotent and gated by PB_BOOTSTRAP.
The PocketBase superuser is still upserted by the PocketBase container, since
the REST API cannot bootstrap the first superuser.

Move PocketBase to port 8070 (internal + published) and the web app to 8090
across both stacks, with matching CORS defaults.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 15:35:33 +02:00

426 lines
13 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
}
// --- 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
}