@x47base/pocketbase-addon 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +9 -1
- package/FEATURES.md +17 -0
- package/MIGRATION.md +51 -2
- package/NOTICE.md +2 -0
- package/README.md +53 -13
- package/SECURITY-REVIEW.md +96 -0
- package/VERIFY.md +34 -0
- package/backups/concurrency_test.go +43 -0
- package/backups/register.go +11 -0
- package/bin/launcher.test.mjs +31 -0
- package/bin/pocketbase-extension.mjs +17 -3
- package/cmd/edge/main.go +28 -0
- package/cmd/gateway/main.go +84 -0
- package/cmd/hosting/main.go +27 -0
- package/cmd/pocketbase/main.go +12 -0
- package/deploy/README.md +35 -6
- package/deploy/compose.yaml +5 -0
- package/deploy/edge.json +6 -2
- package/edge/gateway.go +110 -35
- package/edge/openapi.json +226 -1
- package/edge/policy.go +23 -12
- package/edge/telemetry.go +187 -0
- package/edge/telemetry_test.go +181 -0
- package/hosting/README.md +62 -0
- package/hosting/backups.go +381 -0
- package/hosting/backups_test.go +81 -0
- package/hosting/blueprint.example.json +14 -0
- package/hosting/blueprint.go +242 -0
- package/hosting/blueprint_test.go +98 -0
- package/hosting/config.go +126 -0
- package/hosting/deploy/dns.example.json +1 -0
- package/hosting/docs/DEPLOYMENT.md +98 -0
- package/hosting/hosting.example.json +1 -0
- package/hosting/local_target_test.go +29 -0
- package/hosting/scripts/dns.mjs +116 -0
- package/hosting/scripts/routes.mjs +39 -0
- package/hosting/ui/main.js +36 -0
- package/hosting/ui/page.css +86 -0
- package/hosting/ui/page.js +112 -0
- package/multinode/README.md +87 -0
- package/multinode/gateway.docker.json +1 -0
- package/multinode/gateway.example.json +1 -0
- package/multinode/gateway.go +347 -0
- package/multinode/gateway_test.go +217 -0
- package/multinode/security_regression_test.go +106 -0
- package/package.json +11 -6
- package/scripts/check-edge.py +21 -4
- package/scripts/check.sh +5 -2
- package/security/README.md +42 -9
- package/security/config.go +4 -0
- package/security/edge_telemetry.go +123 -0
- package/security/edge_telemetry_test.go +87 -0
- package/security/management.go +3 -1
- package/security/openapi.json +269 -0
- package/security/security.go +37 -13
- package/security/security_test.go +54 -0
- package/security/state.go +12 -7
- package/security/ui/dashboard.css +9 -2
- package/security/ui/dashboard.js +68 -20
- package/security/ui/main.js +16 -4
- package/security/ui/model.js +23 -1
- package/security/ui/model.test.mjs +12 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Hosting — @x47base/pocketbase-addon
|
|
2
|
+
|
|
3
|
+
Optional named backup targets, a native **Hosting** dashboard and deployment rendering. Included in the [combined npm package](../README.md); no separate hosting package is needed.
|
|
4
|
+
|
|
5
|
+
## Enable backup policies
|
|
6
|
+
|
|
7
|
+
From your application directory:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npx pocketbase-addon init
|
|
11
|
+
HOSTING_ADDON_CONFIG=./hosting.json PB_SECURITY_MODE=enforce \
|
|
12
|
+
npx pocketbase-addon serve --http=127.0.0.1:8090 --dir=./pb_data
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The initial configuration uses `{"targets":[{"key":"native","kind":"native"}]}`. The native destination follows PocketBase's backup settings. For named local or S3 targets, copy and edit `node_modules/@x47base/pocketbase-addon/hosting/hosting.example.json`. Remove unused targets. Paths must be absolute; local targets must be outside the data directory or under its excluded `backups` directory. Symlink aliases are checked. S3 endpoints must use HTTPS, with bucket, region and credential environment references configured on the server.
|
|
16
|
+
|
|
17
|
+
Administrators select named targets in **Hosting**; client input cannot select arbitrary endpoints or paths. `hosting_backup_policies` and `hosting_backup_runs` default to superuser-only access. Policies support intervals of 5–43,200 minutes and retention of 1–1,000 snapshots. Schedules run once a minute; missed executions coalesce. Manual execution also advances the next scheduled run. Overlapping backup/restore operations are rejected. Configuration target changes require a restart; policy changes do not.
|
|
18
|
+
|
|
19
|
+
Named S3 targets require the base encryption hook and refuse plaintext writes. Supply `PB_BACKUP_ENCRYPTION_KEY` securely and enable encryption in **Settings → x47base extensions**. A completed run verifies that the snapshot object exists; it does not prove restoration. Retention deletes only that policy's snapshot prefix after the new backup exists. Interrupted runs become `unknown` on restart. Inspect the destination before retrying.
|
|
20
|
+
|
|
21
|
+
Backups cover the entire owner database and its local files. They do not include externally stored record files or a separate Vendure/PostgreSQL database. Retrieve alternate-target snapshots securely and use the native backup import/recovery flow on a disposable owner to test recovery. Review restored policies before enabling schedules against production backup destinations.
|
|
22
|
+
|
|
23
|
+
## Embed in Go
|
|
24
|
+
|
|
25
|
+
Use the npm-installed source replacement described in [MIGRATION.md](../MIGRATION.md), then register after the base add-on and before `app.Start()`:
|
|
26
|
+
|
|
27
|
+
```go
|
|
28
|
+
import hosting "github.com/spink-dev/pocketbase-extension/hosting"
|
|
29
|
+
|
|
30
|
+
config, err := hosting.Load(os.Getenv("HOSTING_ADDON_CONFIG"))
|
|
31
|
+
if err != nil { log.Fatal(err) }
|
|
32
|
+
if _, err := hosting.Register(app, config); err != nil { log.Fatal(err) }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Do not register hosting both manually and through the bundled CLI.
|
|
36
|
+
|
|
37
|
+
## Render a commerce deployment
|
|
38
|
+
|
|
39
|
+
This renderer targets a **separate commerce application**. It requires a Vendure integration, custom storefront/domain-guard endpoints and built images. The combined base server does not implement those business endpoints. Generic PocketBase deployments can use the [base deployment guide](../deploy/README.md).
|
|
40
|
+
|
|
41
|
+
```sh
|
|
42
|
+
cp node_modules/@x47base/pocketbase-addon/hosting/blueprint.example.json blueprint.json
|
|
43
|
+
# Edit image tags, domains, email, storage/ingress classes, issuer and egress CIDRs.
|
|
44
|
+
npx pocketbase-addon hosting -blueprint ./blueprint.json -out ./generated
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Rendering creates Compose/Kubernetes JSON, gateway routes, Caddy TLS configuration and a DNS plan. It creates no cloud resources or running services. See [deployment contracts and recovery](docs/DEPLOYMENT.md) for the required files, secrets and image behavior. Build the gateway image from the installed package root with `docker build --target gateway -t your-registry/gateway:your-version node_modules/@x47base/pocketbase-addon`.
|
|
48
|
+
|
|
49
|
+
Public ingress reaches only the gateway; administration and database origins remain private. Never scale an owner above one writer per data directory. Kubernetes templates require an enforcing CNI, CSI storage supporting `ReadWriteOncePod`, ingress, DNS and a TLS issuer. Provider deployment and restore drills need environment-specific validation.
|
|
50
|
+
|
|
51
|
+
### Verified domain export and DNS
|
|
52
|
+
|
|
53
|
+
The separate commerce application must validate domain ownership and enforce live store/domain bindings on the origin. Then:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
# Supply HOSTING_PB_TOKEN via the environment, never committed configuration.
|
|
57
|
+
node node_modules/@x47base/pocketbase-addon/hosting/scripts/routes.mjs https://management.example.com blueprint.json routed.json
|
|
58
|
+
npx pocketbase-addon hosting -blueprint routed.json -out generated
|
|
59
|
+
node node_modules/@x47base/pocketbase-addon/hosting/scripts/dns.mjs dns.json plan
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Start `dns.json` from the packaged `hosting/deploy/dns.example.json` and replace all placeholders. DNS scripts support Cloudflare or Route 53. The explicit `apply` argument makes provider changes; review plans first. Cloudflare uses a named token environment variable; Route 53 uses the AWS CLI credential chain. No credentials are stored in PocketBase. DNS changes and gateway route reloads are separate operations.
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
package hosting
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"embed"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"github.com/pocketbase/dbx"
|
|
9
|
+
"github.com/pocketbase/pocketbase/apis"
|
|
10
|
+
"github.com/pocketbase/pocketbase/core"
|
|
11
|
+
"github.com/pocketbase/pocketbase/tools/filesystem"
|
|
12
|
+
"github.com/pocketbase/pocketbase/tools/hook"
|
|
13
|
+
"io/fs"
|
|
14
|
+
"os"
|
|
15
|
+
"path/filepath"
|
|
16
|
+
"sort"
|
|
17
|
+
"strings"
|
|
18
|
+
"sync"
|
|
19
|
+
"time"
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
const PoliciesCollection = "hosting_backup_policies"
|
|
23
|
+
const RunsCollection = "hosting_backup_runs"
|
|
24
|
+
|
|
25
|
+
//go:embed ui/*
|
|
26
|
+
var assets embed.FS
|
|
27
|
+
|
|
28
|
+
type targetKey struct{}
|
|
29
|
+
type targetApp struct {
|
|
30
|
+
core.App
|
|
31
|
+
target Target
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func (a *targetApp) NewBackupsFilesystem() (*filesystem.System, error) {
|
|
35
|
+
return openTarget(a.App, a.target)
|
|
36
|
+
}
|
|
37
|
+
func openTarget(app core.App, t Target) (*filesystem.System, error) {
|
|
38
|
+
switch t.Kind {
|
|
39
|
+
case "native":
|
|
40
|
+
return app.NewBackupsFilesystem()
|
|
41
|
+
case "local":
|
|
42
|
+
if err := validateLocalTarget(app.DataDir(), t.Path); err != nil {
|
|
43
|
+
return nil, err
|
|
44
|
+
}
|
|
45
|
+
if err := os.MkdirAll(t.Path, 0700); err != nil {
|
|
46
|
+
return nil, err
|
|
47
|
+
}
|
|
48
|
+
return filesystem.NewLocal(t.Path)
|
|
49
|
+
case "s3":
|
|
50
|
+
if os.Getenv(t.AccessKeyEnv) == "" || os.Getenv(t.SecretEnv) == "" {
|
|
51
|
+
return nil, errors.New("backup credentials unavailable")
|
|
52
|
+
}
|
|
53
|
+
f, err := filesystem.NewS3(t.Bucket, t.Region, t.Endpoint, os.Getenv(t.AccessKeyEnv), os.Getenv(t.SecretEnv), t.PathStyle)
|
|
54
|
+
if err != nil {
|
|
55
|
+
return nil, err
|
|
56
|
+
}
|
|
57
|
+
f.OnNewWriter().BindFunc(func(e *filesystem.NewWriterEvent) error {
|
|
58
|
+
if !strings.HasSuffix(e.FileKey, ".zip.age") {
|
|
59
|
+
return errors.New("remote backups require the base add-on's encrypted backup mode")
|
|
60
|
+
}
|
|
61
|
+
return e.Next()
|
|
62
|
+
})
|
|
63
|
+
return f, nil
|
|
64
|
+
}
|
|
65
|
+
return nil, errors.New("unknown target")
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type Service struct {
|
|
69
|
+
owner context.Context
|
|
70
|
+
app core.App
|
|
71
|
+
config Config
|
|
72
|
+
runMu sync.Mutex
|
|
73
|
+
backupMu sync.Mutex
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
func (s *Service) target(key string) (Target, error) {
|
|
77
|
+
for _, t := range s.config.Targets {
|
|
78
|
+
if t.Key == key {
|
|
79
|
+
return t, nil
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return Target{}, errors.New("unknown target")
|
|
83
|
+
}
|
|
84
|
+
func ensureCollections(app core.App) error {
|
|
85
|
+
return app.RunInTransaction(func(tx core.App) error {
|
|
86
|
+
if _, err := tx.FindCollectionByNameOrId(PoliciesCollection); err != nil {
|
|
87
|
+
c := core.NewBaseCollection(PoliciesCollection)
|
|
88
|
+
c.Fields.Add(&core.TextField{Name: "name", Required: true, Max: 128}, &core.TextField{Name: "target", Required: true, Max: 64}, &core.BoolField{Name: "enabled"}, &core.NumberField{Name: "intervalMinutes", Required: true, OnlyInt: true, Min: floatPointer(5), Max: floatPointer(43200)}, &core.NumberField{Name: "keep", Required: true, OnlyInt: true, Min: floatPointer(1), Max: floatPointer(1000)}, &core.DateField{Name: "nextRun"}, &core.TextField{Name: "lastStatus", Max: 32})
|
|
89
|
+
if err = tx.Save(c); err != nil {
|
|
90
|
+
return err
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if _, err := tx.FindCollectionByNameOrId(RunsCollection); err != nil {
|
|
94
|
+
p, err := tx.FindCollectionByNameOrId(PoliciesCollection)
|
|
95
|
+
if err != nil {
|
|
96
|
+
return err
|
|
97
|
+
}
|
|
98
|
+
c := core.NewBaseCollection(RunsCollection)
|
|
99
|
+
c.Fields.Add(&core.RelationField{Name: "policy", Required: true, CollectionId: p.Id, MaxSelect: 1}, &core.TextField{Name: "target", Required: true, Max: 64}, &core.TextField{Name: "status", Required: true, Max: 32}, &core.TextField{Name: "key", Max: 256}, &core.TextField{Name: "message", Max: 1024}, &core.AutodateField{Name: "created", OnCreate: true}, &core.DateField{Name: "finished"})
|
|
100
|
+
c.AddIndex("idx_hosting_backup_runs", false, "policy, created", "")
|
|
101
|
+
return tx.Save(c)
|
|
102
|
+
}
|
|
103
|
+
return nil
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
func floatPointer(v float64) *float64 { return &v }
|
|
107
|
+
func Register(app core.App, c Config) (*Service, error) {
|
|
108
|
+
if err := c.Validate(); err != nil {
|
|
109
|
+
return nil, err
|
|
110
|
+
}
|
|
111
|
+
s := &Service{app: app, config: c}
|
|
112
|
+
ownerContext, stop := context.WithCancel(context.Background())
|
|
113
|
+
s.owner = ownerContext
|
|
114
|
+
app.OnTerminate().BindFunc(func(e *core.TerminateEvent) error { stop(); s.runMu.Lock(); defer s.runMu.Unlock(); return e.Next() })
|
|
115
|
+
setup := func() error {
|
|
116
|
+
for _, target := range c.Targets {
|
|
117
|
+
if target.Kind == "local" {
|
|
118
|
+
if err := validateLocalTarget(app.DataDir(), target.Path); err != nil {
|
|
119
|
+
return err
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if err := ensureCollections(app); err != nil {
|
|
124
|
+
return err
|
|
125
|
+
}
|
|
126
|
+
rows, err := app.FindRecordsByFilter(RunsCollection, "status='running'", "", 0, 0)
|
|
127
|
+
if err != nil {
|
|
128
|
+
return err
|
|
129
|
+
}
|
|
130
|
+
for _, r := range rows {
|
|
131
|
+
r.Set("status", "unknown")
|
|
132
|
+
r.Set("message", "Owner restarted; inspect the destination before retrying")
|
|
133
|
+
if err = app.Save(r); err != nil {
|
|
134
|
+
return err
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return nil
|
|
138
|
+
}
|
|
139
|
+
if app.IsBootstrapped() {
|
|
140
|
+
if err := setup(); err != nil {
|
|
141
|
+
return nil, err
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
app.OnBootstrap().BindFunc(func(e *core.BootstrapEvent) error {
|
|
145
|
+
if err := e.Next(); err != nil {
|
|
146
|
+
return err
|
|
147
|
+
}
|
|
148
|
+
return setup()
|
|
149
|
+
})
|
|
150
|
+
app.OnBackupCreate().Bind(&hook.Handler[*core.BackupEvent]{Priority: -1000, Func: func(e *core.BackupEvent) error {
|
|
151
|
+
if !s.backupMu.TryLock() {
|
|
152
|
+
return errors.New("backup operation already running")
|
|
153
|
+
}
|
|
154
|
+
defer s.backupMu.Unlock()
|
|
155
|
+
if t, ok := e.Context.Value(targetKey{}).(Target); ok {
|
|
156
|
+
original := e.App
|
|
157
|
+
e.App = &targetApp{App: original, target: t}
|
|
158
|
+
defer func() { e.App = original }()
|
|
159
|
+
}
|
|
160
|
+
return e.Next()
|
|
161
|
+
}})
|
|
162
|
+
app.OnBackupRestore().Bind(&hook.Handler[*core.BackupEvent]{Priority: -1000, Func: func(e *core.BackupEvent) error {
|
|
163
|
+
if !s.backupMu.TryLock() {
|
|
164
|
+
return errors.New("backup operation already running")
|
|
165
|
+
}
|
|
166
|
+
defer s.backupMu.Unlock()
|
|
167
|
+
return e.Next()
|
|
168
|
+
}})
|
|
169
|
+
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
|
170
|
+
bundle, err := fs.Sub(assets, "ui")
|
|
171
|
+
if err != nil {
|
|
172
|
+
return err
|
|
173
|
+
}
|
|
174
|
+
e.UIExtensions = append(e.UIExtensions, core.UIExtension{Name: "x47-hosting", FS: bundle})
|
|
175
|
+
g := e.Router.Group("/api/hosting").Bind(apis.RequireSuperuserAuth(), apis.BodyLimit(4096))
|
|
176
|
+
g.BindFunc(func(e *core.RequestEvent) error {
|
|
177
|
+
e.Response.Header().Set("Cache-Control", "no-store")
|
|
178
|
+
return e.Next()
|
|
179
|
+
})
|
|
180
|
+
g.GET("/backups", func(e *core.RequestEvent) error {
|
|
181
|
+
policies, err := app.FindRecordsByFilter(PoliciesCollection, "", "name", 1000, 0)
|
|
182
|
+
if err != nil {
|
|
183
|
+
return err
|
|
184
|
+
}
|
|
185
|
+
runs, err := app.FindRecordsByFilter(RunsCollection, "", "-created", 50, 0)
|
|
186
|
+
if err != nil {
|
|
187
|
+
return err
|
|
188
|
+
}
|
|
189
|
+
targets := []map[string]string{}
|
|
190
|
+
for _, t := range c.Targets {
|
|
191
|
+
targets = append(targets, map[string]string{"key": t.Key, "kind": t.Kind})
|
|
192
|
+
}
|
|
193
|
+
return e.JSON(200, map[string]any{"policies": policies, "runs": runs, "targets": targets})
|
|
194
|
+
})
|
|
195
|
+
save := func(e *core.RequestEvent) error {
|
|
196
|
+
var input struct {
|
|
197
|
+
Name, Target string
|
|
198
|
+
Enabled bool
|
|
199
|
+
IntervalMinutes, Keep int
|
|
200
|
+
}
|
|
201
|
+
if e.BindBody(&input) != nil {
|
|
202
|
+
return e.BadRequestError("Invalid policy", nil)
|
|
203
|
+
}
|
|
204
|
+
if _, err := s.target(input.Target); err != nil {
|
|
205
|
+
return e.BadRequestError("Unknown target", nil)
|
|
206
|
+
}
|
|
207
|
+
var record *core.Record
|
|
208
|
+
var err error
|
|
209
|
+
if id := e.Request.PathValue("id"); id != "" {
|
|
210
|
+
record, err = app.FindRecordById(PoliciesCollection, id)
|
|
211
|
+
} else {
|
|
212
|
+
var coll *core.Collection
|
|
213
|
+
coll, err = app.FindCollectionByNameOrId(PoliciesCollection)
|
|
214
|
+
if err == nil {
|
|
215
|
+
record = core.NewRecord(coll)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
if err != nil {
|
|
219
|
+
return e.NotFoundError("Policy not found", nil)
|
|
220
|
+
}
|
|
221
|
+
record.Set("name", input.Name)
|
|
222
|
+
record.Set("target", input.Target)
|
|
223
|
+
record.Set("enabled", input.Enabled)
|
|
224
|
+
record.Set("intervalMinutes", input.IntervalMinutes)
|
|
225
|
+
record.Set("keep", input.Keep)
|
|
226
|
+
record.Set("nextRun", time.Now().Add(time.Duration(input.IntervalMinutes)*time.Minute))
|
|
227
|
+
if err = app.Save(record); err != nil {
|
|
228
|
+
return e.BadRequestError("Invalid policy", err)
|
|
229
|
+
}
|
|
230
|
+
return e.JSON(200, record)
|
|
231
|
+
}
|
|
232
|
+
g.POST("/backups/policies", save)
|
|
233
|
+
g.PUT("/backups/policies/{id}", save)
|
|
234
|
+
g.POST("/backups/policies/{id}/run", func(e *core.RequestEvent) error {
|
|
235
|
+
run, err := s.Run(e.Request.Context(), e.Request.PathValue("id"))
|
|
236
|
+
if err != nil {
|
|
237
|
+
return e.JSON(409, map[string]any{"message": err.Error(), "run": run})
|
|
238
|
+
}
|
|
239
|
+
return e.JSON(200, run)
|
|
240
|
+
})
|
|
241
|
+
app.Cron().MustAdd("hosting-backup-policies", "* * * * *", func() { s.Tick(ownerContext) })
|
|
242
|
+
return e.Next()
|
|
243
|
+
})
|
|
244
|
+
return s, nil
|
|
245
|
+
}
|
|
246
|
+
func (s *Service) Tick(ctx context.Context) {
|
|
247
|
+
rows, err := s.app.FindRecordsByFilter(PoliciesCollection, "enabled=true && nextRun<={:now}", "nextRun", 100, 0, dbx.Params{"now": time.Now().UTC().Format("2006-01-02 15:04:05.000Z")})
|
|
248
|
+
if err != nil {
|
|
249
|
+
return
|
|
250
|
+
}
|
|
251
|
+
for _, p := range rows {
|
|
252
|
+
if ctx.Err() != nil {
|
|
253
|
+
return
|
|
254
|
+
}
|
|
255
|
+
_, _ = s.run(ctx, p.Id, true)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
func (s *Service) Run(ctx context.Context, id string) (*core.Record, error) {
|
|
259
|
+
return s.run(ctx, id, false)
|
|
260
|
+
}
|
|
261
|
+
func (s *Service) run(ctx context.Context, id string, scheduled bool) (*core.Record, error) {
|
|
262
|
+
if !s.runMu.TryLock() {
|
|
263
|
+
return nil, errors.New("a scheduled backup is already running")
|
|
264
|
+
}
|
|
265
|
+
defer s.runMu.Unlock()
|
|
266
|
+
if s.owner.Err() != nil {
|
|
267
|
+
return nil, errors.New("owner is stopping")
|
|
268
|
+
}
|
|
269
|
+
p, err := s.app.FindRecordById(PoliciesCollection, id)
|
|
270
|
+
if err != nil {
|
|
271
|
+
return nil, err
|
|
272
|
+
}
|
|
273
|
+
if scheduled && (!p.GetBool("enabled") || p.GetDateTime("nextRun").Time().After(time.Now())) {
|
|
274
|
+
return nil, nil
|
|
275
|
+
}
|
|
276
|
+
target, err := s.target(p.GetString("target"))
|
|
277
|
+
if err != nil {
|
|
278
|
+
return nil, err
|
|
279
|
+
}
|
|
280
|
+
c, err := s.app.FindCollectionByNameOrId(RunsCollection)
|
|
281
|
+
if err != nil {
|
|
282
|
+
return nil, err
|
|
283
|
+
}
|
|
284
|
+
run := core.NewRecord(c)
|
|
285
|
+
run.Set("policy", id)
|
|
286
|
+
run.Set("target", target.Key)
|
|
287
|
+
run.Set("status", "running")
|
|
288
|
+
prefix := "hosting_" + id + "_"
|
|
289
|
+
name := prefix + time.Now().UTC().Format("20060102T150405.000000000") + ".zip"
|
|
290
|
+
run.Set("key", name)
|
|
291
|
+
err = s.app.RunInTransaction(func(tx core.App) error {
|
|
292
|
+
current, err := tx.FindRecordById(PoliciesCollection, id)
|
|
293
|
+
if err != nil {
|
|
294
|
+
return err
|
|
295
|
+
}
|
|
296
|
+
current.Set("nextRun", time.Now().Add(time.Duration(current.GetInt("intervalMinutes"))*time.Minute))
|
|
297
|
+
current.Set("lastStatus", "running")
|
|
298
|
+
if err := tx.Save(current); err != nil {
|
|
299
|
+
return err
|
|
300
|
+
}
|
|
301
|
+
return tx.Save(run)
|
|
302
|
+
})
|
|
303
|
+
if err != nil {
|
|
304
|
+
return nil, err
|
|
305
|
+
}
|
|
306
|
+
ctx, cancel := context.WithTimeout(ctx, 30*time.Minute)
|
|
307
|
+
defer cancel()
|
|
308
|
+
stopOwner := context.AfterFunc(s.owner, cancel)
|
|
309
|
+
defer stopOwner()
|
|
310
|
+
err = s.app.CreateBackup(context.WithValue(ctx, targetKey{}, target), name)
|
|
311
|
+
if err == nil {
|
|
312
|
+
err = s.retain(ctx, target, prefix, name, p.GetInt("keep"), run)
|
|
313
|
+
}
|
|
314
|
+
status, message := "completed", ""
|
|
315
|
+
if err != nil {
|
|
316
|
+
status = "failed"
|
|
317
|
+
message = "Backup or retention failed; inspect owner logs and destination before retrying"
|
|
318
|
+
s.app.Logger().Error("hosting backup failed", "policy", id, "error", err)
|
|
319
|
+
}
|
|
320
|
+
run.Set("status", status)
|
|
321
|
+
run.Set("message", message)
|
|
322
|
+
run.Set("finished", time.Now())
|
|
323
|
+
saveErr := s.app.RunInTransaction(func(tx core.App) error {
|
|
324
|
+
if err := tx.Save(run); err != nil {
|
|
325
|
+
return err
|
|
326
|
+
}
|
|
327
|
+
current, err := tx.FindRecordById(PoliciesCollection, id)
|
|
328
|
+
if err != nil {
|
|
329
|
+
return err
|
|
330
|
+
}
|
|
331
|
+
current.Set("lastStatus", status)
|
|
332
|
+
return tx.Save(current)
|
|
333
|
+
})
|
|
334
|
+
if saveErr != nil {
|
|
335
|
+
return run, errors.New("backup outcome persistence failed; inspect destination")
|
|
336
|
+
}
|
|
337
|
+
if err != nil {
|
|
338
|
+
return run, errors.New(message)
|
|
339
|
+
}
|
|
340
|
+
return run, nil
|
|
341
|
+
}
|
|
342
|
+
func (s *Service) retain(ctx context.Context, target Target, prefix, name string, keep int, run *core.Record) error {
|
|
343
|
+
f, err := openTarget(s.app, target)
|
|
344
|
+
if err != nil {
|
|
345
|
+
return err
|
|
346
|
+
}
|
|
347
|
+
defer f.Close()
|
|
348
|
+
f.SetContext(ctx)
|
|
349
|
+
found := false
|
|
350
|
+
for _, key := range []string{name + ".age", name} {
|
|
351
|
+
found, err = f.Exists(key)
|
|
352
|
+
if err != nil {
|
|
353
|
+
return err
|
|
354
|
+
}
|
|
355
|
+
if found {
|
|
356
|
+
run.Set("key", key)
|
|
357
|
+
break
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if !found {
|
|
361
|
+
return errors.New("snapshot completion could not be verified")
|
|
362
|
+
}
|
|
363
|
+
objects, err := f.List(prefix)
|
|
364
|
+
if err != nil {
|
|
365
|
+
return err
|
|
366
|
+
}
|
|
367
|
+
sort.Slice(objects, func(i, j int) bool { return objects[i].Key > objects[j].Key })
|
|
368
|
+
owned := 0
|
|
369
|
+
for _, o := range objects {
|
|
370
|
+
if o.IsDir || !strings.HasPrefix(o.Key, prefix) || filepath.Base(o.Key) != o.Key || !(strings.HasSuffix(o.Key, ".zip") || strings.HasSuffix(o.Key, ".zip.age")) {
|
|
371
|
+
continue
|
|
372
|
+
}
|
|
373
|
+
owned++
|
|
374
|
+
if owned > keep {
|
|
375
|
+
if err = f.Delete(o.Key); err != nil {
|
|
376
|
+
return fmt.Errorf("backup saved; retention incomplete: %w", err)
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
return nil
|
|
381
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
package hosting
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"github.com/pocketbase/pocketbase/core"
|
|
6
|
+
"github.com/pocketbase/pocketbase/tests"
|
|
7
|
+
"os"
|
|
8
|
+
"path/filepath"
|
|
9
|
+
"strings"
|
|
10
|
+
"testing"
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
func TestDynamicBackupsRetainOnlyOwnedSnapshots(t *testing.T) {
|
|
14
|
+
app, err := tests.NewTestApp()
|
|
15
|
+
if err != nil {
|
|
16
|
+
t.Fatal(err)
|
|
17
|
+
}
|
|
18
|
+
defer app.Cleanup()
|
|
19
|
+
dir := t.TempDir()
|
|
20
|
+
s, err := Register(app, Config{Targets: []Target{{Key: "local", Kind: "local", Path: dir}}})
|
|
21
|
+
if err != nil {
|
|
22
|
+
t.Fatal(err)
|
|
23
|
+
}
|
|
24
|
+
c, _ := app.FindCollectionByNameOrId(PoliciesCollection)
|
|
25
|
+
p := core.NewRecord(c)
|
|
26
|
+
p.Set("name", "Hourly")
|
|
27
|
+
p.Set("target", "local")
|
|
28
|
+
p.Set("intervalMinutes", 60)
|
|
29
|
+
p.Set("keep", 1)
|
|
30
|
+
if err = app.Save(p); err != nil {
|
|
31
|
+
t.Fatal(err)
|
|
32
|
+
}
|
|
33
|
+
os.WriteFile(filepath.Join(dir, "unrelated.zip"), []byte("preserve"), 0600)
|
|
34
|
+
if run, err := s.run(context.Background(), p.Id, true); err != nil || run != nil {
|
|
35
|
+
t.Fatal("disabled scheduled policy ran", err)
|
|
36
|
+
}
|
|
37
|
+
first, err := s.Run(context.Background(), p.Id)
|
|
38
|
+
if err != nil {
|
|
39
|
+
t.Fatal(err)
|
|
40
|
+
}
|
|
41
|
+
second, err := s.Run(context.Background(), p.Id)
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
if first.GetString("status") != "completed" || second.GetString("status") != "completed" {
|
|
46
|
+
t.Fatal("backup failed")
|
|
47
|
+
}
|
|
48
|
+
if _, err = os.Stat(filepath.Join(dir, first.GetString("key"))); !os.IsNotExist(err) {
|
|
49
|
+
t.Fatal("retention failed")
|
|
50
|
+
}
|
|
51
|
+
if _, err = os.Stat(filepath.Join(dir, second.GetString("key"))); err != nil {
|
|
52
|
+
t.Fatal(err)
|
|
53
|
+
}
|
|
54
|
+
if _, err = os.Stat(filepath.Join(dir, "unrelated.zip")); err != nil {
|
|
55
|
+
t.Fatal("deleted unrelated backup")
|
|
56
|
+
}
|
|
57
|
+
policies, _ := app.FindCollectionByNameOrId(PoliciesCollection)
|
|
58
|
+
if policies.ListRule != nil || policies.CreateRule != nil {
|
|
59
|
+
t.Fatal("public policy management")
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
func TestBackupConfigurationRejectsUnsafeTargets(t *testing.T) {
|
|
63
|
+
for _, c := range []Config{{Targets: []Target{{Key: "local", Kind: "local", Path: "relative"}}}, {Targets: []Target{{Key: "s3", Kind: "s3", Endpoint: "http://storage", Bucket: "b", AccessKeyEnv: "ACCESS", SecretEnv: "SECRET"}}}, {Targets: []Target{{Key: "local", Kind: "native"}, {Key: "local", Kind: "native"}}}} {
|
|
64
|
+
if c.Validate() == nil {
|
|
65
|
+
t.Fatal("unsafe target accepted")
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
func TestRemoteBackupRejectsUnencryptedWrites(t *testing.T) {
|
|
70
|
+
t.Setenv("BACKUP_ACCESS", "test")
|
|
71
|
+
t.Setenv("BACKUP_SECRET", "test")
|
|
72
|
+
f, err := openTarget(nil, Target{Key: "remote", Kind: "s3", Endpoint: "https://storage.example.invalid", Bucket: "backups", Region: "us-east-1", AccessKeyEnv: "BACKUP_ACCESS", SecretEnv: "BACKUP_SECRET"})
|
|
73
|
+
if err != nil {
|
|
74
|
+
t.Fatal(err)
|
|
75
|
+
}
|
|
76
|
+
defer f.Close()
|
|
77
|
+
err = f.Upload([]byte("plaintext"), "hosting_test.zip")
|
|
78
|
+
if err == nil || !strings.Contains(err.Error(), "encrypted") {
|
|
79
|
+
t.Fatal("plaintext upload permitted", err)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name":"commerce",
|
|
3
|
+
"pocketbaseImage":"registry.example.com/commerce-owner:1.0.0",
|
|
4
|
+
"vendureImage":"registry.example.com/commerce-vendure:1.0.0",
|
|
5
|
+
"gatewayImage":"registry.example.com/commerce-gateway:1.0.0",
|
|
6
|
+
"postgresImage":"postgres:18.6-alpine",
|
|
7
|
+
"tlsImage":"caddy:2.10.2-alpine",
|
|
8
|
+
"email":"operations@example.com",
|
|
9
|
+
"domains":[{"host":"team.example.com","store":"team-autumn"}],
|
|
10
|
+
"storageClass":"standard",
|
|
11
|
+
"ingressClass":"nginx",
|
|
12
|
+
"issuer":"letsencrypt-production",
|
|
13
|
+
"egressCIDRs":[]
|
|
14
|
+
}
|