Every attachment — a document scan, a fuel receipt, a workshop invoice, a
photo of a part's box — has lived inside pb_data, in a directory beside the
SQLite file. One volume held both, so neither could be sized, backed up or
moved without the other. PocketBase can keep those bytes in an S3 bucket
instead, and now it is told to.
Nothing on the way to a client changes, because an attachment was never a
storage URL to begin with: it is fetched from GET /api/{records}/{id}/file,
which re-checks car access and asks PocketBase for the bytes as the service
account. PocketBase streams from the bucket through that same endpoint rather
than redirecting to it, so the web app, the phone and the plugin cannot tell
which side of the switch they are on.
The bootstrap that already creates the collections now writes PocketBase's
files-storage settings too, from PB_S3_*, on every boot and only when they
differ from what is already there — then asks PocketBase to prove it can reach
the bucket, and says so in the log either way. Two asymmetries are deliberate.
A read of the settings masks the stored secret, so a rotation of the secret
alone is invisible from here and needs another PB_S3_* to move with it. And it
never turns S3 back off: files already written to a bucket are reachable only
while PocketBase still points at it, so dropping the configuration would strand
them rather than undo anything.
Each deployment shape is one compose file with an .env example of the same
name, not a base plus an overlay to remember — six of each per folder, for
Docker and Docker-AIO alike: the plain one, .seaweedfs, .s3, and the three prod
twins. The SeaweedFS files run master, volume, filer and gateway as one process
and a one-shot init container beside it, because PocketBase never issues a
CreateBucket and SeaweedFS will not conjure one on first upload. The credentials
do double duty there — the gateway's only identity is also what PocketBase
authenticates with. In the all-in-one that gateway is a second container rather
than a fourth process under supervisord: keeping the object store inside the
image, on the volume the files are being moved off, would have defeated the
point and would have meant rebuilding.
Files uploaded before the switch are not carried across; PocketBase copies
nothing, and both READMEs say so where an operator will read it.
The TLS overlay and its Caddyfile go. The section they served stays, without
them: nothing in the stack terminates TLS any more, so it now names the four
variables to set in front of whichever proxy already does — TRUST_FORWARDED_PROTO
being the one that decides whether a charger is believed about how it arrived.
Unexercised: this was written on a machine without Docker, so the pinned
SeaweedFS image, the bucket-create and the settings write have not been run
against a live stack. The Go side builds, vets and tests clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
581 lines
18 KiB
Go
581 lines
18 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
|
|
|
|
// S3, when non-nil and Enabled, points PocketBase's record-file storage at
|
|
// a bucket. Nil is the normal case — a stack started without one of the
|
|
// SeaweedFS compose overlays keeps its uploads on the pb_data volume.
|
|
S3 *S3Options
|
|
}
|
|
|
|
// S3Options describes the bucket PocketBase should keep record files in. It
|
|
// mirrors PocketBase's own settings block one field at a time, so there is
|
|
// nothing to translate at the wire.
|
|
type S3Options struct {
|
|
Enabled bool
|
|
Bucket string
|
|
Region string
|
|
Endpoint string
|
|
AccessKey string
|
|
Secret string
|
|
ForcePathStyle bool
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
if err := ensureFileStorage(ctx, client, opts.S3); err != nil {
|
|
return fmt.Errorf("file storage: %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
|
|
}
|
|
|
|
// --- file storage ----------------------------------------------------------
|
|
|
|
// storageSettings is the slice of PocketBase's settings this step owns. The
|
|
// json names are PocketBase's own (core.S3Config), so the PATCH body is just
|
|
// this type marshalled back out.
|
|
type storageSettings struct {
|
|
Enabled bool `json:"enabled"`
|
|
Bucket string `json:"bucket"`
|
|
Region string `json:"region"`
|
|
Endpoint string `json:"endpoint"`
|
|
AccessKey string `json:"accessKey"`
|
|
Secret string `json:"secret,omitempty"`
|
|
ForcePathStyle bool `json:"forcePathStyle"`
|
|
}
|
|
|
|
// sameExceptSecret compares everything a read of the settings can be trusted
|
|
// on. PocketBase masks the stored secret, so a rotation of the secret alone is
|
|
// invisible from here — changing any other PB_S3_* value forces the write, and
|
|
// so does editing it in the admin UI.
|
|
func (s storageSettings) sameExceptSecret(other storageSettings) bool {
|
|
s.Secret, other.Secret = "", ""
|
|
return s == other
|
|
}
|
|
|
|
// ensureFileStorage points PocketBase's record-file storage at the configured
|
|
// bucket, and does nothing at all when no bucket was asked for.
|
|
//
|
|
// It never turns S3 *off*: files already written to a bucket are only reachable
|
|
// while PocketBase is still pointed at it, so dropping the overlay leaves the
|
|
// setting where it is rather than stranding every existing attachment. Moving
|
|
// back to local storage is a deliberate act in the admin UI.
|
|
func ensureFileStorage(ctx context.Context, client *pb.Client, opts *S3Options) error {
|
|
if opts == nil || !opts.Enabled {
|
|
return nil // not requested
|
|
}
|
|
want := storageSettings{
|
|
Enabled: true,
|
|
Bucket: opts.Bucket,
|
|
Region: opts.Region,
|
|
Endpoint: opts.Endpoint,
|
|
AccessKey: opts.AccessKey,
|
|
Secret: opts.Secret,
|
|
ForcePathStyle: opts.ForcePathStyle,
|
|
}
|
|
|
|
raw, status, err := client.Raw(ctx, http.MethodGet, "/api/settings", nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("read settings: status %d: %s", status, raw)
|
|
}
|
|
var current struct {
|
|
S3 storageSettings `json:"s3"`
|
|
}
|
|
if err := json.Unmarshal(raw, ¤t); err != nil {
|
|
return err
|
|
}
|
|
|
|
if current.S3.sameExceptSecret(want) {
|
|
log.Printf("bootstrap: • file storage already on S3 (%s)", want.Bucket)
|
|
} else {
|
|
// Only the s3 block is sent: everything else in the settings — mail,
|
|
// backups, rate limits — belongs to whoever set it.
|
|
raw, status, err = client.Raw(ctx, http.MethodPatch, "/api/settings",
|
|
map[string]any{"s3": want})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if status < 200 || status >= 300 {
|
|
return fmt.Errorf("apply settings: status %d: %s", status, raw)
|
|
}
|
|
log.Printf("bootstrap: ✓ file storage → S3 (%s at %s)", want.Bucket, want.Endpoint)
|
|
}
|
|
|
|
testFileStorage(ctx, client)
|
|
return nil
|
|
}
|
|
|
|
// testFileStorage asks PocketBase to prove it can actually reach the bucket,
|
|
// and only says so in the log. A failure here means uploads will fail, but the
|
|
// server still has to come up — the endpoint is fixable from the panel, and a
|
|
// stack that refuses to boot cannot be fixed from anywhere.
|
|
func testFileStorage(ctx context.Context, client *pb.Client) {
|
|
raw, status, err := client.Raw(ctx, http.MethodPost, "/api/settings/test/s3",
|
|
map[string]any{"filesystem": "storage"})
|
|
switch {
|
|
case err != nil:
|
|
log.Printf("bootstrap: WARNING: S3 storage test failed: %v", err)
|
|
case status < 200 || status >= 300:
|
|
log.Printf("bootstrap: WARNING: S3 storage unreachable (status %d): %s", status, raw)
|
|
default:
|
|
log.Printf("bootstrap: ✓ S3 storage reachable")
|
|
}
|
|
}
|
|
|
|
// --- 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
|
|
}
|