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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1e76c2b7f9
commit
30c9cdebe9
@@ -288,6 +288,15 @@ The Node scripts read `POCKETBASE_URL` / `POCKETBASE_ADMIN_EMAIL` /
|
|||||||
> adding a new car spec field, extend `DESIRED.cars` in `setup-pocketbase.mjs`,
|
> adding a new car spec field, extend `DESIRED.cars` in `setup-pocketbase.mjs`,
|
||||||
> add it to `models.Car` + the record mapping in `records.go`, then rebuild.
|
> add it to `models.Car` + the record mapping in `records.go`, then rebuild.
|
||||||
|
|
||||||
|
> **Startup bootstrap:** the server also runs this same create/reconcile on boot
|
||||||
|
> (`internal/bootstrap`, a Go mirror of `setup-pocketbase.mjs`) whenever a service
|
||||||
|
> account is configured, so the Docker prod stack needs no manual setup step. It
|
||||||
|
> is idempotent and gated by `PB_BOOTSTRAP` (default `true`; set to `false` to
|
||||||
|
> skip). With `DRIVERVAULT_SUPERADMIN_EMAIL` + `DRIVERVAULT_SUPERADMIN_PASSWORD`
|
||||||
|
> set it also creates the first `superadmin` user when absent. **Keep the two
|
||||||
|
> schemas in sync:** a change to `DESIRED` in the script must be mirrored in
|
||||||
|
> `internal/bootstrap/schema.go` (guarded by `TestSchemaConsistency`).
|
||||||
|
|
||||||
## First-time setup
|
## First-time setup
|
||||||
|
|
||||||
1. **Create the PocketBase collections** (idempotent — safe to re-run on an
|
1. **Create the PocketBase collections** (idempotent — safe to re-run on an
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"drivervault/apiserver/internal/api"
|
"drivervault/apiserver/internal/api"
|
||||||
|
"drivervault/apiserver/internal/bootstrap"
|
||||||
"drivervault/apiserver/internal/config"
|
"drivervault/apiserver/internal/config"
|
||||||
"drivervault/apiserver/internal/pb"
|
"drivervault/apiserver/internal/pb"
|
||||||
)
|
)
|
||||||
@@ -44,6 +45,26 @@ func main() {
|
|||||||
log.Println("WARNING: POCKETBASE_ADMIN_EMAIL/PASSWORD unset — management endpoints return 503 until configured")
|
log.Println("WARNING: POCKETBASE_ADMIN_EMAIL/PASSWORD unset — management endpoints return 503 until configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bring PocketBase up to the expected schema (create missing collections,
|
||||||
|
// reconcile existing ones) and ensure the super-admin. Idempotent, so it runs
|
||||||
|
// on every boot. Non-fatal: a fresh PocketBase that isn't reachable yet, or a
|
||||||
|
// bad service account, must not stop the panel from coming up so a superadmin
|
||||||
|
// can log in and fix the connection.
|
||||||
|
if cfg.Bootstrap && cfg.AdminConfigured() {
|
||||||
|
bootCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
|
if err := bootstrap.Run(bootCtx, client, bootstrap.Options{
|
||||||
|
UsersCollection: cfg.UsersCollection,
|
||||||
|
SuperAdminEmail: cfg.SuperAdminEmail,
|
||||||
|
SuperAdminPassword: cfg.SuperAdminPassword,
|
||||||
|
SuperAdminName: cfg.SuperAdminName,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("WARNING: bootstrap failed: %v", err)
|
||||||
|
} else {
|
||||||
|
log.Println("bootstrap: PocketBase schema ready")
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
}
|
||||||
|
|
||||||
srv := api.New(cfg, client)
|
srv := api.New(cfg, client)
|
||||||
|
|
||||||
if err := srv.StartPlugins(); err != nil {
|
if err := srv.StartPlugins(); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,425 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestRenderFieldModern locks the v0.23+ ("fields") wire shape for each field
|
||||||
|
// type against what PocketBase expects.
|
||||||
|
func TestRenderFieldModern(t *testing.T) {
|
||||||
|
idByName := map[string]string{"cars": "col_cars"}
|
||||||
|
|
||||||
|
rel := renderField(fRelation("car", "cars", true, true), "fields", idByName)
|
||||||
|
if rel["collectionId"] != "col_cars" || rel["cascadeDelete"] != true ||
|
||||||
|
rel["maxSelect"] != 1 || rel["minSelect"] != 0 || rel["required"] != true {
|
||||||
|
t.Fatalf("relation rendered wrong: %#v", rel)
|
||||||
|
}
|
||||||
|
|
||||||
|
nonCascade := renderField(fRelation("owner", "users", false, false), "fields", map[string]string{"users": "u"})
|
||||||
|
if nonCascade["cascadeDelete"] != false {
|
||||||
|
t.Fatalf("non-cascading relation should keep cascadeDelete=false: %#v", nonCascade)
|
||||||
|
}
|
||||||
|
|
||||||
|
sel := renderField(fSelect("result", []string{"passed", "failed"}, false), "fields", idByName)
|
||||||
|
if sel["maxSelect"] != 1 {
|
||||||
|
t.Fatalf("select maxSelect: %#v", sel)
|
||||||
|
}
|
||||||
|
if vals, ok := sel["values"].([]string); !ok || len(vals) != 2 {
|
||||||
|
t.Fatalf("select values: %#v", sel["values"])
|
||||||
|
}
|
||||||
|
|
||||||
|
ad := renderField(fAutodate("created", true, false), "fields", idByName)
|
||||||
|
if ad["onCreate"] != true || ad["onUpdate"] != false {
|
||||||
|
t.Fatalf("autodate: %#v", ad)
|
||||||
|
}
|
||||||
|
|
||||||
|
file := renderField(attachment(), "fields", idByName)
|
||||||
|
if file["maxSize"] != 10485760 || file["maxSelect"] != 1 {
|
||||||
|
t.Fatalf("file: %#v", file)
|
||||||
|
}
|
||||||
|
if mt, ok := file["mimeTypes"].([]string); !ok || len(mt) != 5 {
|
||||||
|
t.Fatalf("file mimeTypes: %#v", file["mimeTypes"])
|
||||||
|
}
|
||||||
|
|
||||||
|
j := renderField(fJSON("params", 10000), "fields", idByName)
|
||||||
|
if j["maxSize"] != 10000 {
|
||||||
|
t.Fatalf("json: %#v", j)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRenderFieldLegacy checks that the legacy ("schema") layout nests options.
|
||||||
|
func TestRenderFieldLegacy(t *testing.T) {
|
||||||
|
rel := renderField(fRelation("car", "cars", true, true), "schema", map[string]string{"cars": "col_cars"})
|
||||||
|
opts, ok := rel["options"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("legacy relation should nest options: %#v", rel)
|
||||||
|
}
|
||||||
|
if opts["collectionId"] != "col_cars" || opts["cascadeDelete"] != true {
|
||||||
|
t.Fatalf("legacy relation options: %#v", opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSameValues covers the select-reconcile comparison (order-insensitive).
|
||||||
|
func TestSameValues(t *testing.T) {
|
||||||
|
if !sameValues([]any{"a", "b"}, []string{"b", "a"}) {
|
||||||
|
t.Fatal("same set in different order should compare equal")
|
||||||
|
}
|
||||||
|
if sameValues([]any{"a"}, []string{"a", "b"}) {
|
||||||
|
t.Fatal("differing lengths should not be equal")
|
||||||
|
}
|
||||||
|
if sameValues([]any{"a", "c"}, []string{"a", "b"}) {
|
||||||
|
t.Fatal("differing members should not be equal")
|
||||||
|
}
|
||||||
|
if sameValues(nil, []string{"a"}) {
|
||||||
|
t.Fatal("nil current should not equal non-empty want")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchemaConsistency guards the ordered lists against the schema map.
|
||||||
|
func TestSchemaConsistency(t *testing.T) {
|
||||||
|
for _, name := range createOrder {
|
||||||
|
if _, ok := collectionsSchema[name]; !ok {
|
||||||
|
t.Errorf("createOrder references unknown collection %q", name)
|
||||||
|
}
|
||||||
|
if name == "users" {
|
||||||
|
t.Errorf("users must not be in createOrder (built-in auth collection)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range reconcileOrder {
|
||||||
|
if _, ok := collectionsSchema[name]; !ok {
|
||||||
|
t.Errorf("reconcileOrder references unknown collection %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name := range indexes {
|
||||||
|
if _, ok := collectionsSchema[name]; !ok {
|
||||||
|
t.Errorf("indexes references unknown collection %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
// This file is the Go mirror of the DESIRED schema, create order, reconcile
|
||||||
|
// order and INDEXES in scripts/setup-pocketbase.mjs. Keep the two in sync: edit
|
||||||
|
// here and re-run the server (bootstrap applies on startup), or run the script.
|
||||||
|
//
|
||||||
|
// Access rules are left null on every collection on purpose — every client goes
|
||||||
|
// through the API Server, which authenticates as a superuser, so the database is
|
||||||
|
// never exposed directly (including attachments, proxied by the API).
|
||||||
|
|
||||||
|
// collectionsSchema is the desired field set per collection.
|
||||||
|
var collectionsSchema = map[string][]fieldDef{
|
||||||
|
"cars": {
|
||||||
|
fText("name", true),
|
||||||
|
fText("make", false),
|
||||||
|
fText("model", false),
|
||||||
|
fNumber("year"),
|
||||||
|
fText("registration", false),
|
||||||
|
fText("registration_country", false),
|
||||||
|
fText("vin", false),
|
||||||
|
fNumber("service_interval_days"),
|
||||||
|
fNumber("service_interval_km"),
|
||||||
|
// Roadworthiness inspection cycle. Only prefills a check's next-due date.
|
||||||
|
fNumber("technical_check_interval_days"),
|
||||||
|
fText("oil_spec", false),
|
||||||
|
fText("transmission_oil_spec", false),
|
||||||
|
fText("differential_oil_spec", false),
|
||||||
|
fText("brake_fluid_spec", false),
|
||||||
|
fText("coolant_spec", false),
|
||||||
|
fNumber("current_km"),
|
||||||
|
// Bi-fuel LPG conversions are their own choice: the car runs on either tank.
|
||||||
|
fSelect("fuel_type", []string{
|
||||||
|
"petrol", "petrol_lpg", "diesel", "diesel_lpg", "hybrid", "electric", "hydrogen",
|
||||||
|
}, false),
|
||||||
|
fText("build_date", false), // ISO YYYY-MM-DD (date-only)
|
||||||
|
fText("first_registration_date", false), // ISO YYYY-MM-DD
|
||||||
|
// Owner of this car. Non-cascading: deleting a user must not wipe their cars.
|
||||||
|
fRelation("owner", "users", false, false),
|
||||||
|
},
|
||||||
|
"service_records": {
|
||||||
|
fRelation("car", "cars", true, true),
|
||||||
|
fDate("date", true),
|
||||||
|
fNumber("km"),
|
||||||
|
fBool("changed_oil"),
|
||||||
|
fBool("changed_engine_air_filter"),
|
||||||
|
fBool("changed_cabin_air_filter"),
|
||||||
|
fText("notes", false),
|
||||||
|
attachment(), // the workshop receipt / stamped service-book page
|
||||||
|
},
|
||||||
|
// Mandatory roadworthiness inspections (przegląd techniczny / MOT / TÜV).
|
||||||
|
"technical_checks": {
|
||||||
|
fRelation("car", "cars", true, true),
|
||||||
|
fDate("date", true),
|
||||||
|
fSelect("result", []string{"passed", "failed"}, false),
|
||||||
|
fNumber("cost"),
|
||||||
|
fText("station", false),
|
||||||
|
fDate("valid_until", false),
|
||||||
|
fText("notes", false),
|
||||||
|
attachment(), // the certificate
|
||||||
|
},
|
||||||
|
"parts": {
|
||||||
|
fRelation("car", "cars", true, true),
|
||||||
|
fText("name", true),
|
||||||
|
fText("part_number", false),
|
||||||
|
fText("category", false),
|
||||||
|
fText("notes", false),
|
||||||
|
attachment(), // a photo of the box, or the part's spec sheet
|
||||||
|
},
|
||||||
|
// Fuel refills. Consumption is derived on read, not stored.
|
||||||
|
"fuel_entries": {
|
||||||
|
fRelation("car", "cars", true, true),
|
||||||
|
fDate("date", true),
|
||||||
|
fNumber("km"), // odometer at the pump
|
||||||
|
fNumber("liters"),
|
||||||
|
fNumber("cost"),
|
||||||
|
fBool("full_tank"),
|
||||||
|
fBool("missed_fill"),
|
||||||
|
fText("station", false),
|
||||||
|
fText("notes", false),
|
||||||
|
attachment(), // the pump receipt
|
||||||
|
},
|
||||||
|
// Workshop visits and repairs (unplanned/one-off garage work with a labour bill).
|
||||||
|
"maintenance_entries": {
|
||||||
|
fRelation("car", "cars", true, true),
|
||||||
|
fDate("date", true),
|
||||||
|
fNumber("km"),
|
||||||
|
fSelect("type", []string{"repair", "inspection", "bodywork", "tyres", "diagnostics", "recall", "warranty", "other"}, false),
|
||||||
|
fSelect("status", []string{"scheduled", "in_progress", "completed"}, false),
|
||||||
|
fText("workshop", false),
|
||||||
|
fText("location", false),
|
||||||
|
fText("description", false),
|
||||||
|
fText("parts_used", false),
|
||||||
|
fNumber("labor_cost"),
|
||||||
|
fNumber("parts_cost"),
|
||||||
|
fText("invoice_number", false),
|
||||||
|
fDate("warranty_until", false),
|
||||||
|
fText("notes", false),
|
||||||
|
attachment(), // the workshop's invoice
|
||||||
|
},
|
||||||
|
// Insurance, pollution certificates, registration papers … expiry drives reminders.
|
||||||
|
"car_documents": {
|
||||||
|
fRelation("car", "cars", true, true),
|
||||||
|
fSelect("type", []string{"insurance", "pollution", "registration", "inspection", "roadTax", "warranty", "other"}, false),
|
||||||
|
fText("title", true),
|
||||||
|
fText("provider", false),
|
||||||
|
fText("reference", false),
|
||||||
|
fDate("issue_date", false),
|
||||||
|
fDate("expiry_date", false),
|
||||||
|
fNumber("cost"),
|
||||||
|
fText("notes", false),
|
||||||
|
attachment(), // the scan/PDF of the paperwork
|
||||||
|
},
|
||||||
|
// User-set reminders (derived document/service ones are computed on read).
|
||||||
|
"reminders": {
|
||||||
|
fRelation("car", "cars", true, true),
|
||||||
|
fText("title", true),
|
||||||
|
fSelect("type", []string{"maintenance", "document", "service", "inspection", "other"}, false),
|
||||||
|
fDate("due_date", false),
|
||||||
|
fNumber("due_km"),
|
||||||
|
fNumber("repeat_days"),
|
||||||
|
fNumber("repeat_km"),
|
||||||
|
fBool("done"),
|
||||||
|
fDate("done_at", false),
|
||||||
|
fText("notes", false),
|
||||||
|
},
|
||||||
|
// Per-car sharing grants. Cascades on both relations.
|
||||||
|
"car_shares": {
|
||||||
|
fRelation("car", "cars", true, true),
|
||||||
|
fRelation("user", "users", true, true),
|
||||||
|
fSelect("permission", []string{"read", "write"}, true),
|
||||||
|
fAutodate("created", true, false),
|
||||||
|
},
|
||||||
|
// Append-only audit trail for OCPP charger control. Actor/org stored as plain
|
||||||
|
// text ids (not relations) so the trail survives user or org deletion.
|
||||||
|
"control_audit": {
|
||||||
|
fText("user_id", false),
|
||||||
|
fText("org_id", false),
|
||||||
|
fText("serial", false),
|
||||||
|
fText("action", true),
|
||||||
|
fText("result", false),
|
||||||
|
fJSON("params", 10000),
|
||||||
|
fAutodate("created", true, false),
|
||||||
|
},
|
||||||
|
// Tenants that users belong to.
|
||||||
|
"organizations": {
|
||||||
|
fText("name", true),
|
||||||
|
fAutodate("created", true, false),
|
||||||
|
// Per-organization plugin/integration config (middle layer of the cascade).
|
||||||
|
fJSON("pluginSettings", 100000),
|
||||||
|
},
|
||||||
|
// Custom fields layered onto the built-in "users" auth collection.
|
||||||
|
"users": {
|
||||||
|
fText("bio", false),
|
||||||
|
fSelect("theme", []string{"light", "dark", "system"}, false),
|
||||||
|
fText("locale", false),
|
||||||
|
fSelect("date_format", []string{"YMD", "DMY_NUM", "DMY", "MDY"}, false),
|
||||||
|
fSelect("currency", []string{
|
||||||
|
"EUR", "GBP", "CHF", "PLN", "CZK", "HUF", "RON", "BGN", "DKK", "SEK", "NOK",
|
||||||
|
"ISK", "ALL", "AMD", "AZN", "BAM", "BYN", "GEL", "MDL", "MKD", "RSD", "RUB",
|
||||||
|
"TRY", "UAH", "USD", "CAD", "AUD", "JPY",
|
||||||
|
}, false),
|
||||||
|
fSelect("font_size", []string{"small", "medium", "large"}, false),
|
||||||
|
fDate("deletion_requested_at", false),
|
||||||
|
// Access role. Empty value is treated as "user" by the API.
|
||||||
|
fSelect("role", []string{"user", "admin", "superadmin"}, false),
|
||||||
|
// Organization membership. Non-cascading: deleting an org keeps its people.
|
||||||
|
fRelation("organization", "organizations", false, false),
|
||||||
|
// Per-user plugin/integration config (bottom layer of the cascade).
|
||||||
|
fJSON("pluginSettings", 100000),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// createOrder is the dependency order for creating missing collections.
|
||||||
|
// "users" is PocketBase's built-in auth collection and is never created here.
|
||||||
|
var createOrder = []string{
|
||||||
|
"organizations",
|
||||||
|
"cars",
|
||||||
|
"service_records",
|
||||||
|
"technical_checks",
|
||||||
|
"parts",
|
||||||
|
"car_shares",
|
||||||
|
"fuel_entries",
|
||||||
|
"maintenance_entries",
|
||||||
|
"car_documents",
|
||||||
|
"reminders",
|
||||||
|
"control_audit",
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconcileOrder additionally includes "users" so its custom fields (role,
|
||||||
|
// organization, preferences) are added to the built-in collection.
|
||||||
|
var reconcileOrder = []string{
|
||||||
|
"organizations",
|
||||||
|
"users",
|
||||||
|
"cars",
|
||||||
|
"service_records",
|
||||||
|
"technical_checks",
|
||||||
|
"parts",
|
||||||
|
"car_shares",
|
||||||
|
"fuel_entries",
|
||||||
|
"maintenance_entries",
|
||||||
|
"car_documents",
|
||||||
|
"reminders",
|
||||||
|
"control_audit",
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexes are extra SQL indexes applied at collection-create time.
|
||||||
|
var indexes = map[string][]string{
|
||||||
|
"organizations": {"CREATE UNIQUE INDEX `idx_organizations_name` ON `organizations` (`name`)"},
|
||||||
|
"fuel_entries": {"CREATE INDEX `idx_fuel_entries_car_km` ON `fuel_entries` (`car`, `km`)"},
|
||||||
|
"maintenance_entries": {"CREATE INDEX `idx_maintenance_entries_car_date` ON `maintenance_entries` (`car`, `date`)"},
|
||||||
|
"car_documents": {"CREATE INDEX `idx_car_documents_car_expiry` ON `car_documents` (`car`, `expiry_date`)"},
|
||||||
|
"reminders": {"CREATE INDEX `idx_reminders_car_due` ON `reminders` (`car`, `due_date`)"},
|
||||||
|
"technical_checks": {"CREATE INDEX `idx_technical_checks_car_date` ON `technical_checks` (`car`, `date`)"},
|
||||||
|
"control_audit": {
|
||||||
|
"CREATE INDEX `idx_control_audit_serial_created` ON `control_audit` (`serial`, `created`)",
|
||||||
|
"CREATE INDEX `idx_control_audit_user_created` ON `control_audit` (`user_id`, `created`)",
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -36,6 +36,18 @@ type Config struct {
|
|||||||
// the charger at, instead of deriving it from request headers.
|
// the charger at, instead of deriving it from request headers.
|
||||||
OCPPRequireTLS bool
|
OCPPRequireTLS bool
|
||||||
OCPPPublicURL string
|
OCPPPublicURL string
|
||||||
|
|
||||||
|
// Bootstrap controls the on-startup PocketBase schema setup: when true (the
|
||||||
|
// default) the server creates any missing collections and reconciles existing
|
||||||
|
// ones against the desired schema, then ensures the super-admin below exists.
|
||||||
|
// It requires a configured service account; without one it is a no-op.
|
||||||
|
Bootstrap bool
|
||||||
|
|
||||||
|
// Super-admin account created during bootstrap when both fields are set and no
|
||||||
|
// user with that email exists yet. Existing accounts are left untouched.
|
||||||
|
SuperAdminEmail string
|
||||||
|
SuperAdminPassword string
|
||||||
|
SuperAdminName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnvFile is the .env path (relative to the working directory) that Load reads
|
// EnvFile is the .env path (relative to the working directory) that Load reads
|
||||||
@@ -63,6 +75,10 @@ func Load() Config {
|
|||||||
PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"),
|
PocketBaseAdminPassword: firstEnv("POCKETBASE_ADMIN_PASSWORD", "PB_ADMIN_PASSWORD"),
|
||||||
OCPPRequireTLS: boolEnv("OCPP_REQUIRE_TLS", true),
|
OCPPRequireTLS: boolEnv("OCPP_REQUIRE_TLS", true),
|
||||||
OCPPPublicURL: strings.TrimRight(getenv("OCPP_PUBLIC_URL", ""), "/"),
|
OCPPPublicURL: strings.TrimRight(getenv("OCPP_PUBLIC_URL", ""), "/"),
|
||||||
|
Bootstrap: boolEnv("PB_BOOTSTRAP", true),
|
||||||
|
SuperAdminEmail: firstEnv("DRIVERVAULT_SUPERADMIN_EMAIL", "SUPERADMIN_EMAIL"),
|
||||||
|
SuperAdminPassword: firstEnv("DRIVERVAULT_SUPERADMIN_PASSWORD", "SUPERADMIN_PASSWORD"),
|
||||||
|
SuperAdminName: getenv("DRIVERVAULT_SUPERADMIN_NAME", "Administrator"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-2
@@ -5,9 +5,22 @@
|
|||||||
PB_ADMIN_EMAIL=admin@example.com
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
PB_ADMIN_PASSWORD=change-me-long-password
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Set to false to skip schema creation/reconcile once the database is set up.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# Allowed CORS origin(s) — match your web origin / WEB_PORT.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
|
||||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
WEB_PORT=80
|
WEB_PORT=8090
|
||||||
PB_PORT=8090
|
PB_PORT=8070
|
||||||
# API Server + its embedded web panel (served at the API root, http://host:8080/).
|
# API Server + its embedded web panel (served at the API root, http://host:8080/).
|
||||||
API_PORT=8080
|
API_PORT=8080
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# DriverVault all-in-one — production config.
|
||||||
|
# Copy to .env and fill in, then:
|
||||||
|
# docker compose -f docker-compose.prod.yml pull
|
||||||
|
# docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# --- Registry image ----------------------------------------------------------
|
||||||
|
AIO_IMAGE=10.2.1.10:5500/admin/drivervault-aio:latest
|
||||||
|
|
||||||
|
# --- PocketBase superuser (required) -----------------------------------------
|
||||||
|
# Created/updated on first boot. The API Server uses these to manage the database.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Set to false to skip schema creation/reconcile once the database is set up.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# Allowed CORS origin(s) — match your public web URL / WEB_PORT.
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
|
||||||
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
|
WEB_PORT=8090
|
||||||
|
PB_PORT=8070
|
||||||
|
API_PORT=8080
|
||||||
|
|
||||||
|
# --- Storage -----------------------------------------------------------------
|
||||||
|
# Default is a Docker-managed named volume ("pb_data"). Set PB_DATA to an
|
||||||
|
# absolute host path for a bind mount, e.g. PB_DATA=/srv/drivervault/pb_data
|
||||||
|
PB_DATA=pb_data
|
||||||
+13
-8
@@ -9,13 +9,16 @@
|
|||||||
#
|
#
|
||||||
# Run it (all three services start together):
|
# Run it (all three services start together):
|
||||||
#
|
#
|
||||||
# docker run -d --name drivervault -p 80:80 -p 8090:8090 \
|
# docker run -d --name drivervault -p 80:80 -p 8070:8070 \
|
||||||
# -e PB_ADMIN_EMAIL=admin@example.com \
|
# -e PB_ADMIN_EMAIL=admin@example.com \
|
||||||
# -e PB_ADMIN_PASSWORD=change-me \
|
# -e PB_ADMIN_PASSWORD=change-me \
|
||||||
|
# -e DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com \
|
||||||
|
# -e DRIVERVAULT_SUPERADMIN_PASSWORD=change-me \
|
||||||
# -v drivervault_pb:/pb/pb_data \
|
# -v drivervault_pb:/pb/pb_data \
|
||||||
# drivervault-aio
|
# drivervault-aio
|
||||||
#
|
#
|
||||||
# Then: web app on http://host/ and PocketBase admin on http://host:8090/_/
|
# Then: web app on http://host/ and PocketBase admin on http://host:8070/_/
|
||||||
|
# On first boot the API Server creates the collections and the super-admin.
|
||||||
|
|
||||||
# --- Stage 1: build the Go API Server ---------------------------------------
|
# --- Stage 1: build the Go API Server ---------------------------------------
|
||||||
FROM golang:1.26-alpine AS api-build
|
FROM golang:1.26-alpine AS api-build
|
||||||
@@ -115,7 +118,7 @@ logfile_maxbytes=0
|
|||||||
; PocketBase: upsert the superuser (idempotent) then serve.
|
; PocketBase: upsert the superuser (idempotent) then serve.
|
||||||
[program:pocketbase]
|
[program:pocketbase]
|
||||||
directory=/pb
|
directory=/pb
|
||||||
command=/bin/sh -c '/pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8090'
|
command=/bin/sh -c '/pb/pocketbase superuser upsert "$PB_ADMIN_EMAIL" "$PB_ADMIN_PASSWORD" 2>/dev/null || true; exec /pb/pocketbase serve --http=0.0.0.0:8070'
|
||||||
priority=10
|
priority=10
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
@@ -126,7 +129,7 @@ stderr_logfile_maxbytes=0
|
|||||||
|
|
||||||
; API Server: wait for PocketBase to be healthy, then start.
|
; API Server: wait for PocketBase to be healthy, then start.
|
||||||
[program:api-server]
|
[program:api-server]
|
||||||
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8090/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server'
|
command=/bin/sh -c 'until wget -qO- http://127.0.0.1:8070/api/health >/dev/null 2>&1; do echo "waiting for pocketbase..."; sleep 1; done; exec /usr/local/bin/api-server'
|
||||||
priority=20
|
priority=20
|
||||||
autostart=true
|
autostart=true
|
||||||
autorestart=true
|
autorestart=true
|
||||||
@@ -148,15 +151,17 @@ SUPERVISOR
|
|||||||
|
|
||||||
# API Server config: everything is local to this container.
|
# API Server config: everything is local to this container.
|
||||||
ENV PORT=8080 \
|
ENV PORT=8080 \
|
||||||
PB_URL=http://127.0.0.1:8090 \
|
PB_URL=http://127.0.0.1:8070 \
|
||||||
CORS_ALLOW_ORIGINS=http://localhost \
|
CORS_ALLOW_ORIGINS=http://localhost:8090 \
|
||||||
AUTH_USERS_COLLECTION=users
|
AUTH_USERS_COLLECTION=users
|
||||||
|
|
||||||
# Required at runtime (no safe defaults): PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD.
|
# 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.
|
||||||
# Pass them with `docker run -e ...`.
|
# Pass them with `docker run -e ...`.
|
||||||
|
|
||||||
VOLUME /pb/pb_data
|
VOLUME /pb/pb_data
|
||||||
# 80 = Web App, 8090 = PocketBase admin, 8080 = API Server + embedded API panel.
|
# 80 = Web App, 8070 = PocketBase admin, 8080 = API Server + embedded API panel.
|
||||||
EXPOSE 80 8090 8080
|
EXPOSE 80 8070 8080
|
||||||
|
|
||||||
CMD ["supervisord", "-c", "/etc/supervisord.conf"]
|
CMD ["supervisord", "-c", "/etc/supervisord.conf"]
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
name: drivervault-aio
|
||||||
|
|
||||||
|
# Production all-in-one — pulls the prebuilt image from the registry instead of
|
||||||
|
# building. One container runs PocketBase + API Server + Web App (nginx).
|
||||||
|
# Everything an operator needs to set lives in .env.
|
||||||
|
#
|
||||||
|
# 1. cp .env.prod.example .env (then edit it)
|
||||||
|
# 2. docker compose -f docker-compose.prod.yml pull
|
||||||
|
# 3. docker compose -f docker-compose.prod.yml up -d
|
||||||
|
#
|
||||||
|
# On first boot PocketBase upserts the superuser from PB_ADMIN_*, and the API
|
||||||
|
# Server creates any missing collections and the DriverVault super-admin from
|
||||||
|
# DRIVERVAULT_SUPERADMIN_*. Both steps are idempotent.
|
||||||
|
|
||||||
|
services:
|
||||||
|
drivervault:
|
||||||
|
image: "${AIO_IMAGE:-10.2.1.10:5500/admin/drivervault-aio:latest}"
|
||||||
|
container_name: drivervault-aio
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
# Schema + super-admin bootstrap on boot (idempotent). Set PB_BOOTSTRAP=false
|
||||||
|
# to skip once the database is established.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
ports:
|
||||||
|
- "${WEB_PORT:-8090}:80" # Web App
|
||||||
|
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||||
|
- "${API_PORT:-8080}:8080" # API Server + embedded API web panel (root /)
|
||||||
|
volumes:
|
||||||
|
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
|
||||||
|
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
@@ -22,9 +22,17 @@ services:
|
|||||||
# Superuser (also used by the API Server to authenticate to PocketBase).
|
# Superuser (also used by the API Server to authenticate to PocketBase).
|
||||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
# Match CORS to the web origin (only used if a browser calls the API directly).
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
# Schema + super-admin bootstrap on boot (idempotent). Set PB_BOOTSTRAP=false
|
||||||
|
# to skip once the database is established.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-80}:80" # Web App
|
- "${WEB_PORT:-8090}:80" # Web App
|
||||||
- "${PB_PORT:-8090}:8090" # PocketBase admin UI / API
|
- "${PB_PORT:-8070}:8070" # PocketBase admin UI / API
|
||||||
- "${API_PORT:-8080}:8080" # API Server + embedded API web panel (root /)
|
- "${API_PORT:-8080}:8080" # API Server + embedded API web panel (root /)
|
||||||
volumes:
|
volumes:
|
||||||
- pb_data:/pb/pb_data
|
- pb_data:/pb/pb_data
|
||||||
|
|||||||
+3
-3
@@ -7,13 +7,13 @@ PB_ADMIN_PASSWORD=change-me-long-password
|
|||||||
# --- API Server -------------------------------------------------------------
|
# --- API Server -------------------------------------------------------------
|
||||||
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
|
# Allowed CORS origin(s) for the web app (match WEB_PORT / your public URL).
|
||||||
# Native mobile apps are not subject to CORS.
|
# Native mobile apps are not subject to CORS.
|
||||||
CORS_ALLOW_ORIGINS=http://localhost:8081
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
AUTH_USERS_COLLECTION=users
|
AUTH_USERS_COLLECTION=users
|
||||||
|
|
||||||
# --- Host port mappings (optional; defaults shown) --------------------------
|
# --- Host port mappings (optional; defaults shown) --------------------------
|
||||||
PB_PORT=8090
|
PB_PORT=8070
|
||||||
API_PORT=8080
|
API_PORT=8080
|
||||||
WEB_PORT=8081
|
WEB_PORT=8090
|
||||||
|
|
||||||
# --- Web App build -----------------------------------------------------------
|
# --- Web App build -----------------------------------------------------------
|
||||||
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
|
# Leave empty so the browser uses same-origin /api (proxied by the BFF).
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# DriverVault — production stack config.
|
||||||
|
# Copy to .env and fill in, then:
|
||||||
|
# docker compose -f docker-compose.prod.yml pull
|
||||||
|
# docker compose -f docker-compose.prod.yml up -d
|
||||||
|
|
||||||
|
# --- Registry images ---------------------------------------------------------
|
||||||
|
# Defaults point at the internal registry; override to pin a tag or use a mirror.
|
||||||
|
PB_IMAGE=10.2.1.10:5500/admin/drivervault-pocketbase:latest
|
||||||
|
API_IMAGE=10.2.1.10:5500/admin/drivervault-api-server:latest
|
||||||
|
WEB_IMAGE=10.2.1.10:5500/admin/drivervault-web-app:latest
|
||||||
|
|
||||||
|
# --- PocketBase superuser ----------------------------------------------------
|
||||||
|
# Created/updated on the PocketBase container's first boot. The API Server uses
|
||||||
|
# these same credentials to manage the database. REQUIRED.
|
||||||
|
PB_ADMIN_EMAIL=admin@example.com
|
||||||
|
PB_ADMIN_PASSWORD=change-me-long-password
|
||||||
|
|
||||||
|
# --- DriverVault super-admin (app login) -------------------------------------
|
||||||
|
# The first application user, created by the API Server on boot with role
|
||||||
|
# "superadmin" if no user with this email exists yet. Leave blank to skip and
|
||||||
|
# create the first user by hand. This is the account you log in to the web app
|
||||||
|
# with — distinct from the PocketBase superuser above.
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL=owner@example.com
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD=change-me-long-password
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME=Administrator
|
||||||
|
|
||||||
|
# Set to false to skip schema creation/reconcile once the database is set up.
|
||||||
|
PB_BOOTSTRAP=true
|
||||||
|
|
||||||
|
# --- API Server --------------------------------------------------------------
|
||||||
|
# Allowed CORS origin(s) for the web app (match your public URL / WEB_PORT).
|
||||||
|
CORS_ALLOW_ORIGINS=http://localhost:8090
|
||||||
|
AUTH_USERS_COLLECTION=users
|
||||||
|
|
||||||
|
# --- Ports -------------------------------------------------------------------
|
||||||
|
# WEB_PORT is the public front door (bound on all interfaces).
|
||||||
|
WEB_PORT=8090
|
||||||
|
# PocketBase admin UI and the API panel are bound to localhost only by default.
|
||||||
|
# Set PB_BIND / API_BIND to 0.0.0.0 to expose them on the network.
|
||||||
|
PB_PORT=8070
|
||||||
|
PB_BIND=127.0.0.1
|
||||||
|
API_PORT=8080
|
||||||
|
API_BIND=127.0.0.1
|
||||||
|
|
||||||
|
# --- Storage -----------------------------------------------------------------
|
||||||
|
# Default is a Docker-managed named volume ("pb_data"). To store the database on
|
||||||
|
# a host path instead, set PB_DATA to an absolute path, e.g.:
|
||||||
|
# PB_DATA=/srv/drivervault/pb_data
|
||||||
|
PB_DATA=pb_data
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
name: drivervault
|
||||||
|
|
||||||
|
# Production DriverVault stack — pulls prebuilt images from the registry instead
|
||||||
|
# of building from source. Everything an operator needs to set lives in .env.
|
||||||
|
#
|
||||||
|
# 1. cp .env.prod.example .env (then edit it — all secrets/ports/volumes)
|
||||||
|
# 2. docker compose -f docker-compose.prod.yml pull
|
||||||
|
# 3. docker compose -f docker-compose.prod.yml up -d
|
||||||
|
#
|
||||||
|
# Traffic flow (browser): Web App BFF --/api--> API Server --> PocketBase.
|
||||||
|
#
|
||||||
|
# On first boot:
|
||||||
|
# • PocketBase upserts the superuser from PB_ADMIN_* (create-if-missing).
|
||||||
|
# • the API Server creates any missing collections, reconciles existing ones,
|
||||||
|
# and creates the DriverVault super-admin from DRIVERVAULT_SUPERADMIN_*.
|
||||||
|
# Both steps are idempotent, so restarts and upgrades are safe.
|
||||||
|
|
||||||
|
services:
|
||||||
|
pocketbase:
|
||||||
|
image: "${PB_IMAGE:-10.2.1.10:5500/admin/drivervault-pocketbase:latest}"
|
||||||
|
container_name: drivervault-pocketbase
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
# The superuser is created/updated on boot (the API Server authenticates
|
||||||
|
# with it). This is the only place the first superuser can be created — the
|
||||||
|
# REST API cannot bootstrap it.
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL:?set PB_ADMIN_EMAIL in .env}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD:?set PB_ADMIN_PASSWORD in .env}"
|
||||||
|
volumes:
|
||||||
|
# Named volume by default; set PB_DATA to a host path in .env for a bind mount.
|
||||||
|
- "${PB_DATA:-pb_data}:/pb/pb_data"
|
||||||
|
ports:
|
||||||
|
# Bound to localhost by default — the admin UI (/_/) is reachable only on
|
||||||
|
# the host. Set PB_BIND=0.0.0.0 in .env to expose it on the network.
|
||||||
|
- "${PB_BIND:-127.0.0.1}:${PB_PORT:-8070}:8070"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 12
|
||||||
|
start_period: 10s
|
||||||
|
|
||||||
|
api-server:
|
||||||
|
image: "${API_IMAGE:-10.2.1.10:5500/admin/drivervault-api-server:latest}"
|
||||||
|
container_name: drivervault-api
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
pocketbase:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PORT: "8080"
|
||||||
|
# Reach PocketBase by its service name on the internal network.
|
||||||
|
PB_URL: "http://pocketbase:8070"
|
||||||
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||||
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||||
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
|
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||||
|
# Schema + super-admin bootstrap (idempotent). Set PB_BOOTSTRAP=false to
|
||||||
|
# skip it once the database is established.
|
||||||
|
PB_BOOTSTRAP: "${PB_BOOTSTRAP:-true}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_EMAIL: "${DRIVERVAULT_SUPERADMIN_EMAIL:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_PASSWORD: "${DRIVERVAULT_SUPERADMIN_PASSWORD:-}"
|
||||||
|
DRIVERVAULT_SUPERADMIN_NAME: "${DRIVERVAULT_SUPERADMIN_NAME:-Administrator}"
|
||||||
|
ports:
|
||||||
|
# Localhost-only by default (the Web App reaches it over the internal
|
||||||
|
# network). Set API_BIND=0.0.0.0 to expose the API panel on the network.
|
||||||
|
- "${API_BIND:-127.0.0.1}:${API_PORT:-8080}:8080"
|
||||||
|
|
||||||
|
web-app:
|
||||||
|
image: "${WEB_IMAGE:-10.2.1.10:5500/admin/drivervault-web-app:latest}"
|
||||||
|
container_name: drivervault-web
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
- api-server
|
||||||
|
environment:
|
||||||
|
# The BFF reverse-proxies /api/* to the API Server over the internal network.
|
||||||
|
API_BASE: "http://api-server:8080"
|
||||||
|
ports:
|
||||||
|
# The public front door. Bound on all interfaces so browsers can reach it.
|
||||||
|
- "${WEB_PORT:-8090}:8090"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pb_data:
|
||||||
@@ -18,10 +18,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- pb_data:/pb/pb_data
|
- pb_data:/pb/pb_data
|
||||||
ports:
|
ports:
|
||||||
# Admin UI / API exposed on the host for management (http://host:8090/_/).
|
# Admin UI / API exposed on the host for management (http://host:8070/_/).
|
||||||
- "${PB_PORT:-8090}:8090"
|
- "${PB_PORT:-8070}:8070"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8090/api/health || exit 1"]
|
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:8070/api/health || exit 1"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 12
|
retries: 12
|
||||||
@@ -39,12 +39,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
PORT: "8080"
|
PORT: "8080"
|
||||||
# Reach PocketBase by its service name on the internal network.
|
# Reach PocketBase by its service name on the internal network.
|
||||||
PB_URL: "http://pocketbase:8090"
|
PB_URL: "http://pocketbase:8070"
|
||||||
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
PB_ADMIN_EMAIL: "${PB_ADMIN_EMAIL}"
|
||||||
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
PB_ADMIN_PASSWORD: "${PB_ADMIN_PASSWORD}"
|
||||||
# Same-origin requests go through the Web App BFF, so CORS is only needed
|
# Same-origin requests go through the Web App BFF, so CORS is only needed
|
||||||
# if the browser ever calls the API Server directly. Default to the web origin.
|
# if the browser ever calls the API Server directly. Default to the web origin.
|
||||||
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8081}"
|
CORS_ALLOW_ORIGINS: "${CORS_ALLOW_ORIGINS:-http://localhost:8090}"
|
||||||
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
AUTH_USERS_COLLECTION: "${AUTH_USERS_COLLECTION:-users}"
|
||||||
ports:
|
ports:
|
||||||
# Optional direct access to the API Server (and its panel at /); the Web
|
# Optional direct access to the API Server (and its panel at /); the Web
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
# The BFF reverse-proxies /api/* to the API Server over the internal network.
|
# The BFF reverse-proxies /api/* to the API Server over the internal network.
|
||||||
API_BASE: "http://api-server:8080"
|
API_BASE: "http://api-server:8080"
|
||||||
ports:
|
ports:
|
||||||
- "${WEB_PORT:-8081}:8090"
|
- "${WEB_PORT:-8090}:8090"
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pb_data:
|
pb_data:
|
||||||
|
|||||||
@@ -29,6 +29,6 @@ RUN chmod +x /entrypoint.sh
|
|||||||
|
|
||||||
# pb_data holds the SQLite database and uploads — mount a volume here.
|
# pb_data holds the SQLite database and uploads — mount a volume here.
|
||||||
VOLUME /pb/pb_data
|
VOLUME /pb/pb_data
|
||||||
EXPOSE 8090
|
EXPOSE 8070
|
||||||
|
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
|
|||||||
@@ -9,4 +9,4 @@ if [ -n "$PB_ADMIN_EMAIL" ] && [ -n "$PB_ADMIN_PASSWORD" ]; then
|
|||||||
|| echo "warning: superuser upsert failed; create an admin via the UI at /_/"
|
|| echo "warning: superuser upsert failed; create an admin via the UI at /_/"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
exec /pb/pocketbase serve --http=0.0.0.0:8090
|
exec /pb/pocketbase serve --http=0.0.0.0:8070
|
||||||
|
|||||||
Reference in New Issue
Block a user