@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,242 @@
|
|
|
1
|
+
package hosting
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"errors"
|
|
6
|
+
"fmt"
|
|
7
|
+
"net"
|
|
8
|
+
"os"
|
|
9
|
+
"path/filepath"
|
|
10
|
+
"regexp"
|
|
11
|
+
"strings"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
type Domain struct {
|
|
15
|
+
Host string `json:"host"`
|
|
16
|
+
Store string `json:"store"`
|
|
17
|
+
}
|
|
18
|
+
type Blueprint struct {
|
|
19
|
+
Name string `json:"name"`
|
|
20
|
+
PocketBaseImage string `json:"pocketbaseImage"`
|
|
21
|
+
VendureImage string `json:"vendureImage"`
|
|
22
|
+
GatewayImage string `json:"gatewayImage"`
|
|
23
|
+
PostgresImage string `json:"postgresImage"`
|
|
24
|
+
TLSImage string `json:"tlsImage"`
|
|
25
|
+
Email string `json:"email"`
|
|
26
|
+
Domains []Domain `json:"domains"`
|
|
27
|
+
StorageClass string `json:"storageClass"`
|
|
28
|
+
IngressClass string `json:"ingressClass"`
|
|
29
|
+
Issuer string `json:"issuer"`
|
|
30
|
+
EgressCIDRs []string `json:"egressCIDRs"`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
func (b Blueprint) Validate() error {
|
|
34
|
+
if !dnsLabel(b.Name) {
|
|
35
|
+
return errors.New("DNS-label deployment name required")
|
|
36
|
+
}
|
|
37
|
+
for _, image := range []string{b.PocketBaseImage, b.VendureImage, b.GatewayImage, b.PostgresImage, b.TLSImage} {
|
|
38
|
+
if strings.ContainsAny(image, " \r\n\t\"'$") || !strings.Contains(image, ":") || strings.HasSuffix(image, ":latest") {
|
|
39
|
+
return errors.New("provide explicit versioned container images")
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if b.Email == "" || strings.ContainsAny(b.Email, " \r\n{}") || len(b.Domains) < 1 || len(b.Domains) > 10000 {
|
|
43
|
+
return errors.New("email and verified domains required")
|
|
44
|
+
}
|
|
45
|
+
if !dnsHost(b.StorageClass) || !dnsHost(b.IngressClass) || !dnsHost(b.Issuer) {
|
|
46
|
+
return errors.New("storageClass, ingressClass and issuer names required")
|
|
47
|
+
}
|
|
48
|
+
seen := map[string]bool{}
|
|
49
|
+
for _, d := range b.Domains {
|
|
50
|
+
if !dnsHost(d.Host) || !strings.Contains(d.Host, ".") || net.ParseIP(d.Host) != nil || seen[d.Host] || !regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`).MatchString(d.Store) {
|
|
51
|
+
return errors.New("invalid domain/store route")
|
|
52
|
+
}
|
|
53
|
+
seen[d.Host] = true
|
|
54
|
+
}
|
|
55
|
+
for _, cidr := range b.EgressCIDRs {
|
|
56
|
+
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
|
57
|
+
return err
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return nil
|
|
61
|
+
}
|
|
62
|
+
func (b Blueprint) Render(dir string) error {
|
|
63
|
+
if err := b.Validate(); err != nil {
|
|
64
|
+
return err
|
|
65
|
+
}
|
|
66
|
+
if err := os.MkdirAll(dir, 0700); err != nil {
|
|
67
|
+
return err
|
|
68
|
+
}
|
|
69
|
+
write := func(name string, value any) error {
|
|
70
|
+
raw, err := json.MarshalIndent(value, "", " ")
|
|
71
|
+
if err != nil {
|
|
72
|
+
return err
|
|
73
|
+
}
|
|
74
|
+
return os.WriteFile(filepath.Join(dir, name), append(raw, '\n'), 0600)
|
|
75
|
+
}
|
|
76
|
+
routes := []map[string]any{}
|
|
77
|
+
for _, d := range b.Domains {
|
|
78
|
+
routes = append(routes, map[string]any{"host": d.Host, "store": d.Store, "origin": "http://pocketbase:8090", "publicPaths": []string{"/api/commerce/stores/" + d.Store, "/api/commerce/stores/" + d.Store + "/offers"}})
|
|
79
|
+
}
|
|
80
|
+
if err := write("gateway.json", map[string]any{"tenants": routes, "allowHTTP": true, "cacheSeconds": 2, "maxConcurrent": 128, "maxBodyBytes": 2097152}); err != nil {
|
|
81
|
+
return err
|
|
82
|
+
}
|
|
83
|
+
if err := write("compose.json", b.compose()); err != nil {
|
|
84
|
+
return err
|
|
85
|
+
}
|
|
86
|
+
if err := write("kubernetes.json", map[string]any{"apiVersion": "v1", "kind": "List", "items": b.kubernetes()}); err != nil {
|
|
87
|
+
return err
|
|
88
|
+
}
|
|
89
|
+
caddy := "{\n email " + b.Email + "\n}\n"
|
|
90
|
+
for _, d := range b.Domains {
|
|
91
|
+
caddy += d.Host + " {\n reverse_proxy gateway:8095\n}\n"
|
|
92
|
+
}
|
|
93
|
+
if err := os.WriteFile(filepath.Join(dir, "Caddyfile"), []byte(caddy), 0600); err != nil {
|
|
94
|
+
return err
|
|
95
|
+
}
|
|
96
|
+
return write("dns-plan.json", map[string]any{"domains": b.Domains, "instructions": "Only export verified PocketBase domain records. Configure TXT ownership proof and A/AAAA/CNAME to your ingress. TLS is provisioned by Caddy (Docker) or cert-manager (Kubernetes). Cloudflare/Route53 changes must use the actual zone and target supplied by your operator."})
|
|
97
|
+
}
|
|
98
|
+
func (b Blueprint) compose() map[string]any {
|
|
99
|
+
hard := func(image string, networks []string) map[string]any {
|
|
100
|
+
return map[string]any{"image": image, "restart": "unless-stopped", "read_only": true, "cap_drop": []string{"ALL"}, "security_opt": []string{"no-new-privileges:true"}, "networks": networks, "tmpfs": []string{"/tmp:rw,noexec,nosuid,size=256m"}, "mem_limit": "1g", "cpus": 2}
|
|
101
|
+
}
|
|
102
|
+
gw := hard(b.GatewayImage, []string{"edge", "application"})
|
|
103
|
+
gw["volumes"] = []string{"./gateway.json:/config/gateway.json:ro"}
|
|
104
|
+
gw["user"] = "65532:65532"
|
|
105
|
+
pb := hard(b.PocketBaseImage, []string{"application", "backup_egress"})
|
|
106
|
+
pb["command"] = []string{"serve", "--http=0.0.0.0:8090", "--dir=/data"}
|
|
107
|
+
pb["volumes"] = []string{"pocketbase_data:/data", "./commerce.json:/config/commerce.json:ro", "./hosting.json:/config/hosting.json:ro", "./pb_migrations:/app/pb_migrations:ro"}
|
|
108
|
+
pb["env_file"] = []string{"./owner.env"}
|
|
109
|
+
pb["environment"] = map[string]string{"VENDURE_ADDON_CONFIG": "/config/commerce.json", "HOSTING_ADDON_CONFIG": "/config/hosting.json"}
|
|
110
|
+
pb["ports"] = []string{"127.0.0.1:8090:8090"}
|
|
111
|
+
pb["stop_grace_period"] = "60s"
|
|
112
|
+
vendure := hard(b.VendureImage, []string{"application", "data", "service_egress"})
|
|
113
|
+
vendure["env_file"] = []string{"./vendure.env"}
|
|
114
|
+
vendure["depends_on"] = map[string]any{"postgres": map[string]string{"condition": "service_healthy"}}
|
|
115
|
+
postgres := map[string]any{"image": b.PostgresImage, "restart": "unless-stopped", "env_file": []string{"./postgres.env"}, "volumes": []string{"postgres_data:/var/lib/postgresql"}, "networks": []string{"data"}, "healthcheck": map[string]any{"test": []string{"CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"}, "interval": "10s", "timeout": "5s", "retries": 10}, "mem_limit": "2g", "cpus": 2}
|
|
116
|
+
tls := map[string]any{"image": b.TLSImage, "restart": "unless-stopped", "ports": []string{"80:80", "443:443", "443:443/udp"}, "volumes": []string{"./Caddyfile:/etc/caddy/Caddyfile:ro", "tls_data:/data", "tls_config:/config"}, "networks": []string{"edge"}, "security_opt": []string{"no-new-privileges:true"}, "mem_limit": "512m", "cpus": 1}
|
|
117
|
+
return map[string]any{"name": b.Name, "services": map[string]any{"tls": tls, "gateway": gw, "pocketbase": pb, "vendure": vendure, "postgres": postgres}, "networks": map[string]any{"edge": map[string]any{}, "application": map[string]bool{"internal": true}, "data": map[string]bool{"internal": true}, "backup_egress": map[string]any{}, "service_egress": map[string]any{}}, "volumes": map[string]any{"pocketbase_data": map[string]any{}, "postgres_data": map[string]any{}, "tls_data": map[string]any{}, "tls_config": map[string]any{}}}
|
|
118
|
+
}
|
|
119
|
+
func (b Blueprint) kubernetes() []any {
|
|
120
|
+
namespace := map[string]any{"apiVersion": "v1", "kind": "Namespace", "metadata": map[string]any{"name": b.Name, "labels": map[string]string{"pod-security.kubernetes.io/enforce": "restricted"}}}
|
|
121
|
+
resource := func(kind, name string, spec any) map[string]any {
|
|
122
|
+
return map[string]any{"apiVersion": "v1", "kind": kind, "metadata": map[string]any{"name": name, "namespace": b.Name}, "spec": spec}
|
|
123
|
+
}
|
|
124
|
+
items := []any{namespace}
|
|
125
|
+
for _, name := range []string{"pocketbase", "postgres"} {
|
|
126
|
+
pvc := resource("PersistentVolumeClaim", name+"-data", map[string]any{"accessModes": []string{"ReadWriteOncePod"}, "storageClassName": b.StorageClass, "resources": map[string]any{"requests": map[string]string{"storage": "20Gi"}}})
|
|
127
|
+
items = append(items, pvc)
|
|
128
|
+
}
|
|
129
|
+
ports := map[string]int{"gateway": 8095, "pocketbase": 8090, "vendure": 3000, "postgres": 5432}
|
|
130
|
+
images := map[string]string{"gateway": b.GatewayImage, "pocketbase": b.PocketBaseImage, "vendure": b.VendureImage, "postgres": b.PostgresImage}
|
|
131
|
+
for _, name := range []string{"gateway", "pocketbase", "vendure", "postgres"} {
|
|
132
|
+
replicas := 1
|
|
133
|
+
if name == "gateway" {
|
|
134
|
+
replicas = 2
|
|
135
|
+
}
|
|
136
|
+
container := map[string]any{"name": name, "image": images[name], "ports": []any{map[string]any{"containerPort": ports[name]}}, "resources": map[string]any{"requests": map[string]string{"cpu": "100m", "memory": "256Mi"}, "limits": map[string]string{"cpu": "2", "memory": "2Gi"}}, "securityContext": map[string]any{"allowPrivilegeEscalation": false, "readOnlyRootFilesystem": true, "capabilities": map[string]any{"drop": []string{"ALL"}}}, "readinessProbe": map[string]any{"tcpSocket": map[string]int{"port": ports[name]}, "initialDelaySeconds": 10, "periodSeconds": 10}, "livenessProbe": map[string]any{"tcpSocket": map[string]int{"port": ports[name]}, "initialDelaySeconds": 60, "periodSeconds": 20}, "volumeMounts": []any{map[string]string{"name": "tmp", "mountPath": "/tmp"}}}
|
|
137
|
+
volumes := []any{map[string]any{"name": "tmp", "emptyDir": map[string]string{"sizeLimit": "256Mi"}}}
|
|
138
|
+
mounts := container["volumeMounts"].([]any)
|
|
139
|
+
if name == "pocketbase" || name == "postgres" {
|
|
140
|
+
mount := "/data"
|
|
141
|
+
if name == "postgres" {
|
|
142
|
+
mount = "/var/lib/postgresql"
|
|
143
|
+
}
|
|
144
|
+
mounts = append(mounts, map[string]string{"name": "data", "mountPath": mount})
|
|
145
|
+
volumes = append(volumes, map[string]any{"name": "data", "persistentVolumeClaim": map[string]string{"claimName": name + "-data"}})
|
|
146
|
+
}
|
|
147
|
+
if name == "gateway" || name == "pocketbase" {
|
|
148
|
+
mounts = append(mounts, map[string]any{"name": "config", "mountPath": "/config", "readOnly": true})
|
|
149
|
+
volumes = append(volumes, map[string]any{"name": "config", "configMap": map[string]string{"name": "commerce-config"}})
|
|
150
|
+
}
|
|
151
|
+
if name != "gateway" {
|
|
152
|
+
container["envFrom"] = []any{map[string]any{"secretRef": map[string]string{"name": name + "-env"}}}
|
|
153
|
+
}
|
|
154
|
+
if name == "pocketbase" {
|
|
155
|
+
container["args"] = []string{"serve", "--http=0.0.0.0:8090", "--dir=/data"}
|
|
156
|
+
container["env"] = []any{map[string]string{"name": "VENDURE_ADDON_CONFIG", "value": "/config/commerce.json"}, map[string]string{"name": "HOSTING_ADDON_CONFIG", "value": "/config/hosting.json"}}
|
|
157
|
+
}
|
|
158
|
+
if name == "postgres" {
|
|
159
|
+
mounts = append(mounts, map[string]string{"name": "postgres-run", "mountPath": "/var/run/postgresql"})
|
|
160
|
+
volumes = append(volumes, map[string]any{"name": "postgres-run", "emptyDir": map[string]string{"sizeLimit": "16Mi"}})
|
|
161
|
+
}
|
|
162
|
+
container["volumeMounts"] = mounts
|
|
163
|
+
pod := map[string]any{"automountServiceAccountToken": false, "securityContext": map[string]any{"runAsNonRoot": true, "runAsUser": 65532, "runAsGroup": 65532, "fsGroup": 65532, "seccompProfile": map[string]string{"type": "RuntimeDefault"}}, "containers": []any{container}, "volumes": volumes, "terminationGracePeriodSeconds": 60}
|
|
164
|
+
if name == "postgres" {
|
|
165
|
+
pod["securityContext"].(map[string]any)["runAsUser"] = 70
|
|
166
|
+
pod["securityContext"].(map[string]any)["runAsGroup"] = 70
|
|
167
|
+
pod["securityContext"].(map[string]any)["fsGroup"] = 70
|
|
168
|
+
}
|
|
169
|
+
d := resource("Deployment", name, map[string]any{"replicas": replicas, "strategy": map[string]string{"type": "Recreate"}, "selector": map[string]any{"matchLabels": map[string]string{"app": name}}, "template": map[string]any{"metadata": map[string]any{"labels": map[string]string{"app": name}}, "spec": pod}})
|
|
170
|
+
if name == "gateway" {
|
|
171
|
+
d["spec"].(map[string]any)["strategy"] = map[string]any{"type": "RollingUpdate", "rollingUpdate": map[string]int{"maxUnavailable": 1, "maxSurge": 1}}
|
|
172
|
+
}
|
|
173
|
+
if name == "gateway" || name == "pocketbase" {
|
|
174
|
+
path := "/healthz"
|
|
175
|
+
if name == "pocketbase" {
|
|
176
|
+
path = "/api/health"
|
|
177
|
+
}
|
|
178
|
+
container["readinessProbe"] = map[string]any{"httpGet": map[string]any{"path": path, "port": ports[name]}, "initialDelaySeconds": 5, "periodSeconds": 10}
|
|
179
|
+
}
|
|
180
|
+
d["apiVersion"] = "apps/v1"
|
|
181
|
+
items = append(items, d, resource("Service", name, map[string]any{"selector": map[string]string{"app": name}, "ports": []any{map[string]int{"port": ports[name], "targetPort": ports[name]}}}))
|
|
182
|
+
}
|
|
183
|
+
policy := func(name string, spec any) {
|
|
184
|
+
v := resource("NetworkPolicy", name, spec)
|
|
185
|
+
v["apiVersion"] = "networking.k8s.io/v1"
|
|
186
|
+
items = append(items, v)
|
|
187
|
+
}
|
|
188
|
+
policy("default-deny", map[string]any{"podSelector": map[string]any{}, "policyTypes": []string{"Ingress", "Egress"}})
|
|
189
|
+
// Both ends of each application connection are explicitly allowed.
|
|
190
|
+
for _, edge := range []struct {
|
|
191
|
+
from, to string
|
|
192
|
+
port int
|
|
193
|
+
}{{"gateway", "pocketbase", 8090}, {"pocketbase", "vendure", 3000}, {"vendure", "postgres", 5432}} {
|
|
194
|
+
for _, direction := range []string{"ingress", "egress"} {
|
|
195
|
+
name, peer := edge.to, edge.from
|
|
196
|
+
selector := "from"
|
|
197
|
+
if direction == "egress" {
|
|
198
|
+
name, peer = edge.from, edge.to
|
|
199
|
+
selector = "to"
|
|
200
|
+
}
|
|
201
|
+
policy(name+"-"+direction+"-"+peer, map[string]any{"podSelector": map[string]any{"matchLabels": map[string]string{"app": name}}, direction: []any{map[string]any{selector: []any{map[string]any{"podSelector": map[string]any{"matchLabels": map[string]string{"app": peer}}}}, "ports": []any{map[string]any{"protocol": "TCP", "port": edge.port}}}}})
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
policy("cluster-dns", map[string]any{"podSelector": map[string]any{}, "egress": []any{map[string]any{"to": []any{map[string]any{"namespaceSelector": map[string]any{"matchLabels": map[string]string{"kubernetes.io/metadata.name": "kube-system"}}}}, "ports": []any{map[string]any{"protocol": "UDP", "port": 53}, map[string]any{"protocol": "TCP", "port": 53}}}}})
|
|
205
|
+
policy("ingress-to-gateway", map[string]any{"podSelector": map[string]any{"matchLabels": map[string]string{"app": "gateway"}}, "ingress": []any{map[string]any{"from": []any{map[string]any{"namespaceSelector": map[string]any{"matchLabels": map[string]string{"x47-ingress": "true"}}}}, "ports": []any{map[string]any{"protocol": "TCP", "port": 8095}}}}})
|
|
206
|
+
if len(b.EgressCIDRs) > 0 {
|
|
207
|
+
destinations := []any{}
|
|
208
|
+
for _, cidr := range b.EgressCIDRs {
|
|
209
|
+
destinations = append(destinations, map[string]any{"ipBlock": map[string]string{"cidr": cidr}})
|
|
210
|
+
}
|
|
211
|
+
policy("configured-https-egress", map[string]any{"podSelector": map[string]any{"matchExpressions": []any{map[string]any{"key": "app", "operator": "In", "values": []string{"pocketbase", "vendure"}}}}, "egress": []any{map[string]any{"to": destinations, "ports": []any{map[string]any{"protocol": "TCP", "port": 443}}}}})
|
|
212
|
+
}
|
|
213
|
+
pdb := resource("PodDisruptionBudget", "gateway", map[string]any{"minAvailable": 1, "selector": map[string]any{"matchLabels": map[string]string{"app": "gateway"}}})
|
|
214
|
+
pdb["apiVersion"] = "policy/v1"
|
|
215
|
+
items = append(items, pdb)
|
|
216
|
+
rules := []any{}
|
|
217
|
+
tls := []any{}
|
|
218
|
+
for i, d := range b.Domains {
|
|
219
|
+
rules = append(rules, map[string]any{"host": d.Host, "http": map[string]any{"paths": []any{map[string]any{"path": "/", "pathType": "Prefix", "backend": map[string]any{"service": map[string]any{"name": "gateway", "port": map[string]int{"number": 8095}}}}}}})
|
|
220
|
+
tls = append(tls, map[string]any{"hosts": []string{d.Host}, "secretName": fmt.Sprintf("store-tls-%d", i)})
|
|
221
|
+
}
|
|
222
|
+
ingress := resource("Ingress", "stores", map[string]any{"ingressClassName": b.IngressClass, "rules": rules, "tls": tls})
|
|
223
|
+
ingress["apiVersion"] = "networking.k8s.io/v1"
|
|
224
|
+
ingress["metadata"].(map[string]any)["annotations"] = map[string]string{"cert-manager.io/cluster-issuer": b.Issuer}
|
|
225
|
+
items = append(items, ingress)
|
|
226
|
+
return items
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
func dnsLabel(s string) bool {
|
|
230
|
+
return len(s) <= 63 && regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`).MatchString(s)
|
|
231
|
+
}
|
|
232
|
+
func dnsHost(s string) bool {
|
|
233
|
+
if s == "" || len(s) > 253 {
|
|
234
|
+
return false
|
|
235
|
+
}
|
|
236
|
+
for _, part := range strings.Split(s, ".") {
|
|
237
|
+
if !dnsLabel(part) {
|
|
238
|
+
return false
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return true
|
|
242
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
package hosting
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"github.com/spink-dev/pocketbase-extension/multinode"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
func testBlueprint() Blueprint {
|
|
13
|
+
return Blueprint{Name: "commerce", PocketBaseImage: "example/pocketbase:1.0.0", VendureImage: "example/vendure:1.0.0", GatewayImage: "example/gateway:1.0.0", PostgresImage: "postgres:18.6-alpine", TLSImage: "caddy:2.10.2-alpine", Email: "admin@example.com", Domains: []Domain{{Host: "team.example.com", Store: "team"}}, StorageClass: "standard", IngressClass: "nginx", Issuer: "letsencrypt", EgressCIDRs: []string{"203.0.113.0/24"}}
|
|
14
|
+
}
|
|
15
|
+
func TestRenderPrivateOriginsAndSingleWriter(t *testing.T) {
|
|
16
|
+
b := testBlueprint()
|
|
17
|
+
dir := t.TempDir()
|
|
18
|
+
if err := b.Render(dir); err != nil {
|
|
19
|
+
t.Fatal(err)
|
|
20
|
+
}
|
|
21
|
+
var compose map[string]any
|
|
22
|
+
raw, _ := os.ReadFile(filepath.Join(dir, "compose.json"))
|
|
23
|
+
json.Unmarshal(raw, &compose)
|
|
24
|
+
services := compose["services"].(map[string]any)
|
|
25
|
+
if _, ok := services["postgres"].(map[string]any)["ports"]; ok {
|
|
26
|
+
t.Fatal("public database")
|
|
27
|
+
}
|
|
28
|
+
if _, ok := services["vendure"].(map[string]any)["ports"]; ok {
|
|
29
|
+
t.Fatal("public Vendure origin")
|
|
30
|
+
}
|
|
31
|
+
pb := services["pocketbase"].(map[string]any)
|
|
32
|
+
if pb["ports"].([]any)[0] != "127.0.0.1:8090:8090" {
|
|
33
|
+
t.Fatal("public management")
|
|
34
|
+
}
|
|
35
|
+
var kube struct{ Items []map[string]any }
|
|
36
|
+
raw, _ = os.ReadFile(filepath.Join(dir, "kubernetes.json"))
|
|
37
|
+
json.Unmarshal(raw, &kube)
|
|
38
|
+
deny, pdb := false, false
|
|
39
|
+
for _, v := range kube.Items {
|
|
40
|
+
meta := v["metadata"].(map[string]any)
|
|
41
|
+
if v["kind"] == "NetworkPolicy" && meta["name"] == "default-deny" {
|
|
42
|
+
deny = true
|
|
43
|
+
}
|
|
44
|
+
if v["kind"] == "PodDisruptionBudget" {
|
|
45
|
+
pdb = true
|
|
46
|
+
}
|
|
47
|
+
if v["kind"] == "Deployment" && meta["name"] == "pocketbase" {
|
|
48
|
+
spec := v["spec"].(map[string]any)
|
|
49
|
+
if spec["replicas"] != float64(1) || spec["strategy"].(map[string]any)["type"] != "Recreate" {
|
|
50
|
+
t.Fatal("unsafe SQLite deployment")
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if !deny || !pdb {
|
|
55
|
+
t.Fatal("missing networking or disruption protection")
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
func TestBlueprintRejectsConfigInjection(t *testing.T) {
|
|
59
|
+
for _, host := range []string{"*.example.com", "example.com\n}", "example.com/path", "user@example.com"} {
|
|
60
|
+
b := testBlueprint()
|
|
61
|
+
b.Domains[0].Host = host
|
|
62
|
+
if b.Validate() == nil {
|
|
63
|
+
t.Fatal(host)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
b := testBlueprint()
|
|
67
|
+
b.PocketBaseImage = "example:latest"
|
|
68
|
+
if b.Validate() == nil {
|
|
69
|
+
t.Fatal("mutable image accepted")
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
func TestRenderedRoutesMatchGatewayContract(t *testing.T) {
|
|
74
|
+
b := testBlueprint()
|
|
75
|
+
b.Domains[0].Store = strings.Repeat("a", 64)
|
|
76
|
+
dir := t.TempDir()
|
|
77
|
+
if err := b.Render(dir); err != nil {
|
|
78
|
+
t.Fatal(err)
|
|
79
|
+
}
|
|
80
|
+
f, err := os.Open(filepath.Join(dir, "gateway.json"))
|
|
81
|
+
if err != nil {
|
|
82
|
+
t.Fatal(err)
|
|
83
|
+
}
|
|
84
|
+
defer f.Close()
|
|
85
|
+
c, err := multinode.Load(f)
|
|
86
|
+
if err != nil {
|
|
87
|
+
t.Fatal(err)
|
|
88
|
+
}
|
|
89
|
+
g, err := multinode.New(c)
|
|
90
|
+
if err != nil {
|
|
91
|
+
t.Fatal(err)
|
|
92
|
+
}
|
|
93
|
+
defer g.CloseIdleConnections()
|
|
94
|
+
b.Domains[0].Store += "a"
|
|
95
|
+
if b.Validate() == nil {
|
|
96
|
+
t.Fatal("renderer accepted store rejected by gateway")
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
package hosting
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"github.com/pocketbase/pocketbase/core"
|
|
8
|
+
"io"
|
|
9
|
+
"net/url"
|
|
10
|
+
"os"
|
|
11
|
+
"path/filepath"
|
|
12
|
+
"regexp"
|
|
13
|
+
"strings"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
type Target struct {
|
|
17
|
+
Key string `json:"key"`
|
|
18
|
+
Kind string `json:"kind"`
|
|
19
|
+
Path string `json:"path,omitempty"`
|
|
20
|
+
Bucket string `json:"bucket,omitempty"`
|
|
21
|
+
Region string `json:"region,omitempty"`
|
|
22
|
+
Endpoint string `json:"endpoint,omitempty"`
|
|
23
|
+
AccessKeyEnv string `json:"accessKeyEnv,omitempty"`
|
|
24
|
+
SecretEnv string `json:"secretEnv,omitempty"`
|
|
25
|
+
PathStyle bool `json:"pathStyle,omitempty"`
|
|
26
|
+
}
|
|
27
|
+
type Config struct {
|
|
28
|
+
Targets []Target `json:"targets"`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
var keyPattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,63}$`)
|
|
32
|
+
var envPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
|
|
33
|
+
|
|
34
|
+
func (c Config) Validate() error {
|
|
35
|
+
seen := map[string]bool{}
|
|
36
|
+
if len(c.Targets) < 1 || len(c.Targets) > 32 {
|
|
37
|
+
return errors.New("configure 1-32 backup targets")
|
|
38
|
+
}
|
|
39
|
+
for _, t := range c.Targets {
|
|
40
|
+
if !keyPattern.MatchString(t.Key) || seen[t.Key] {
|
|
41
|
+
return errors.New("invalid or duplicate target key")
|
|
42
|
+
}
|
|
43
|
+
seen[t.Key] = true
|
|
44
|
+
switch t.Kind {
|
|
45
|
+
case "native":
|
|
46
|
+
case "local":
|
|
47
|
+
if !filepath.IsAbs(t.Path) {
|
|
48
|
+
return errors.New("local backup target must be absolute")
|
|
49
|
+
}
|
|
50
|
+
case "s3":
|
|
51
|
+
u, err := url.Parse(t.Endpoint)
|
|
52
|
+
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || t.Bucket == "" || t.Region == "" || !envPattern.MatchString(t.AccessKeyEnv) || !envPattern.MatchString(t.SecretEnv) {
|
|
53
|
+
return errors.New("S3 requires fixed HTTPS endpoint, bucket and credential environment references")
|
|
54
|
+
}
|
|
55
|
+
default:
|
|
56
|
+
return errors.New("unknown backup target kind")
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return nil
|
|
60
|
+
}
|
|
61
|
+
func Load(path string) (Config, error) {
|
|
62
|
+
var c Config
|
|
63
|
+
f, err := os.Open(path)
|
|
64
|
+
if err != nil {
|
|
65
|
+
return c, err
|
|
66
|
+
}
|
|
67
|
+
defer f.Close()
|
|
68
|
+
raw, err := io.ReadAll(io.LimitReader(f, 65537))
|
|
69
|
+
if err != nil || len(raw) > 65536 {
|
|
70
|
+
return c, errors.New("invalid hosting configuration")
|
|
71
|
+
}
|
|
72
|
+
decoder := json.NewDecoder(bytes.NewReader(raw))
|
|
73
|
+
decoder.DisallowUnknownFields()
|
|
74
|
+
if err = decoder.Decode(&c); err != nil {
|
|
75
|
+
return c, err
|
|
76
|
+
}
|
|
77
|
+
if decoder.Decode(new(any)) != io.EOF {
|
|
78
|
+
return c, errors.New("unexpected trailing configuration")
|
|
79
|
+
}
|
|
80
|
+
return c, c.Validate()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Resolve existing parents too, so a not-yet-created directory cannot hide a symlink.
|
|
84
|
+
func resolvedPath(path string) (string, error) {
|
|
85
|
+
path, err := filepath.Abs(path)
|
|
86
|
+
if err != nil {
|
|
87
|
+
return "", err
|
|
88
|
+
}
|
|
89
|
+
resolved, err := filepath.EvalSymlinks(path)
|
|
90
|
+
if err == nil {
|
|
91
|
+
return resolved, nil
|
|
92
|
+
}
|
|
93
|
+
if !os.IsNotExist(err) {
|
|
94
|
+
return "", err
|
|
95
|
+
}
|
|
96
|
+
parent := filepath.Dir(path)
|
|
97
|
+
if parent == path {
|
|
98
|
+
return "", err
|
|
99
|
+
}
|
|
100
|
+
resolved, err = resolvedPath(parent)
|
|
101
|
+
if err != nil {
|
|
102
|
+
return "", err
|
|
103
|
+
}
|
|
104
|
+
return filepath.Join(resolved, filepath.Base(path)), nil
|
|
105
|
+
}
|
|
106
|
+
func validateLocalTarget(dataDir, target string) error {
|
|
107
|
+
data, err := resolvedPath(dataDir)
|
|
108
|
+
if err != nil {
|
|
109
|
+
return err
|
|
110
|
+
}
|
|
111
|
+
dest, err := resolvedPath(target)
|
|
112
|
+
if err != nil {
|
|
113
|
+
return err
|
|
114
|
+
}
|
|
115
|
+
rel, err := filepath.Rel(data, dest)
|
|
116
|
+
if err != nil {
|
|
117
|
+
return err
|
|
118
|
+
}
|
|
119
|
+
if rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) {
|
|
120
|
+
return nil
|
|
121
|
+
}
|
|
122
|
+
if rel == core.LocalBackupsDirName || strings.HasPrefix(rel, core.LocalBackupsDirName+string(os.PathSeparator)) {
|
|
123
|
+
return nil
|
|
124
|
+
}
|
|
125
|
+
return errors.New("local backup target must be outside pb_data or inside its excluded backups directory")
|
|
126
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"provider":"cloudflare","zoneID":"00000000000000000000000000000000","tokenEnv":"CLOUDFLARE_DNS_TOKEN","records":[{"name":"_x47-store.team.example.com","type":"TXT","content":"x47=REPLACE_WITH_POCKETBASE_CHALLENGE","ttl":300},{"name":"team.example.com","type":"CNAME","content":"ingress.example.com","ttl":300}]}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# Docker and Kubernetes deployment
|
|
2
|
+
|
|
3
|
+
## Application image contract
|
|
4
|
+
|
|
5
|
+
Build versioned images before rendering. Use the standard PocketBase Go host plus the base, commerce and hosting registrations; include your own storefront endpoints, assets, business hooks and schema migrations. Do not deploy the local SQL.js/dummy-payment commerce fixture as a production Vendure image.
|
|
6
|
+
|
|
7
|
+
| Image | Required behavior |
|
|
8
|
+
|---|---|
|
|
9
|
+
| PocketBase owner | Entrypoint is the Go binary; accepts `serve --http=0.0.0.0:8090 --dir=/data`. Register all three add-ons. Read `VENDURE_ADDON_CONFIG` and `HOSTING_ADDON_CONFIG`. Bind the custom-domain guard, keep management on the private origin, ship the application's storefront assets. Run as non-root with writable `/data` and `/tmp`. |
|
|
10
|
+
| Vendure | Production Vendure server on port 3000, `shop-api` and `admin-api`; PostgreSQL configuration from environment. Register the commerce campaign and store-scope plugins, use the same store signing secret as the owner. Ship the dashboard and assets, run migrations deliberately before release. Run workers/queues for production jobs according to your Vendure application. |
|
|
11
|
+
| Gateway | Combined package Dockerfile built with `--target gateway`; listens on 8095, reads `/config/gateway.json`, runs as UID 65532. |
|
|
12
|
+
| PostgreSQL | Example targets the official PostgreSQL 18 Alpine layout, UID/GID 70 for Kubernetes, data below `/var/lib/postgresql`. Adapt security context and paths if replacing it with another image. |
|
|
13
|
+
|
|
14
|
+
Use the base add-on's Go/JavaScript migration integration in your owner. Docker mounts `./pb_migrations` at `/app/pb_migrations`; the owner must explicitly register that directory. For Kubernetes, bake immutable migrations into the versioned image and configure their path there. Schema generation/synchronization is not automatically enabled. Back up before schema changes; do not auto-retry failed payment/order mutations.
|
|
15
|
+
|
|
16
|
+
## Docker
|
|
17
|
+
|
|
18
|
+
In `generated/`, provide:
|
|
19
|
+
|
|
20
|
+
- `commerce.json`: real fixed Vendure Admin API URL `http://vendure:3000/admin-api`, configured channel aliases and credential environment references. Explicitly allow private HTTP inside this deployment. Set `domainTarget` to your ingress hostname. Never accept upstream URLs from the browser.
|
|
21
|
+
- `hosting.json`: named backup targets, typically local `/data/backups/archives` plus encrypted S3.
|
|
22
|
+
- `owner.env`: configured Vendure Admin credentials, store signing key and base backup encryption key. Do not place secrets directly in the blueprint.
|
|
23
|
+
- `vendure.env`: database connection to `postgres:5432`, matching store secret and production application settings.
|
|
24
|
+
- `postgres.env`: `POSTGRES_DB`, `POSTGRES_USER`, strong `POSTGRES_PASSWORD`; values must match Vendure's database configuration.
|
|
25
|
+
- `pb_migrations/`: your versioned migrations.
|
|
26
|
+
|
|
27
|
+
Environment key names other than the two add-on config paths and official PostgreSQL keys are your image's contract. Configure the same Vendure store-scope secret in both services. Register backup encryption through the base add-on's settings rather than merely setting a key and assuming encryption is enabled.
|
|
28
|
+
|
|
29
|
+
Rendered files are private by default. Non-root containers need read permission on bind-mounted **nonsecret** gateway/commerce/hosting config files and Caddyfile. Set ownership for the container user or use `chmod 644 gateway.json commerce.json hosting.json Caddyfile` when they contain only public configuration/environment references. Keep `*.env` at mode 600. Ensure your image initializes named volume ownership for its runtime user; never make the database publicly writable.
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
cd generated
|
|
33
|
+
docker compose -f compose.json config --quiet
|
|
34
|
+
docker compose -f compose.json up -d
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Only Caddy exposes public 80/443; management binds `127.0.0.1:8090`. PostgreSQL and Vendure publish no host ports. Use SSH tunneling for remote PocketBase management. For temporary Vendure dashboard access, run an explicitly loopback-bound service override or a private authenticated administration ingress; never put the generic Admin API behind a public shop hostname.
|
|
38
|
+
|
|
39
|
+
Docker networks separate ingress, application, data, backup egress and Vendure service egress. Internal networks have no direct external routing. The two egress networks allow outbound access; they are **not destination allowlists**. Use host firewall rules or an egress proxy for destination restrictions. TLS termination and gateway body/concurrency limits reduce origin work but do not eliminate volumetric attacks against the network uplink.
|
|
40
|
+
|
|
41
|
+
On route changes, bind-mounted files require care: atomic host file replacement may leave a running container on the old bind-mount inode. Recreate the gateway service after replacing `gateway.json`, or mount the config directory and send SIGHUP after validation. Native SIGHUP reload keeps the prior configuration if the replacement is invalid. Recreate/reload Caddy after changing its hostname list.
|
|
42
|
+
|
|
43
|
+
## Kubernetes
|
|
44
|
+
|
|
45
|
+
Use an existing cluster with an enforcing CNI, CSI storage supporting `ReadWriteOncePod`, an ingress controller and a configured cert-manager **DNS-01 ClusterIssuer**. The default-deny policy does not permit arbitrary HTTP-01 solver pods. The storage class and controller names in the blueprint must exist. Namespace labels alone do not install those components.
|
|
46
|
+
|
|
47
|
+
Separate namespaces/projects should use separate rendered blueprints and secrets. Apply to a test namespace first. These example commands change cluster resources; run them only against the intended context:
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
kubectl config current-context
|
|
51
|
+
kubectl apply --dry-run=client --validate=strict -f generated/kubernetes.json
|
|
52
|
+
kubectl create namespace commerce --dry-run=client -o yaml | kubectl apply -f -
|
|
53
|
+
kubectl -n commerce create secret generic pocketbase-env --from-env-file=generated/owner.env
|
|
54
|
+
kubectl -n commerce create secret generic vendure-env --from-env-file=generated/vendure.env
|
|
55
|
+
kubectl -n commerce create secret generic postgres-env --from-env-file=generated/postgres.env
|
|
56
|
+
kubectl -n commerce create configmap commerce-config \
|
|
57
|
+
--from-file=generated/gateway.json --from-file=generated/commerce.json --from-file=generated/hosting.json
|
|
58
|
+
# Label your actual ingress controller namespace, after reviewing its isolation.
|
|
59
|
+
kubectl label namespace YOUR_INGRESS_NAMESPACE x47-ingress=true
|
|
60
|
+
kubectl apply -f generated/kubernetes.json
|
|
61
|
+
kubectl -n commerce rollout status deployment/gateway
|
|
62
|
+
kubectl -n commerce rollout status deployment/pocketbase
|
|
63
|
+
kubectl -n commerce port-forward service/pocketbase 8090:8090
|
|
64
|
+
# In another terminal, for private Vendure administration:
|
|
65
|
+
kubectl -n commerce port-forward service/vendure 19400:3000
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Replace `commerce` with the blueprint's namespace. Update existing secrets/configmaps through your secret management/GitOps process, without committing secret values. Mounted configmap updates do not reload a running gateway by themselves: after the new mounted content is visible, roll out the gateway. Roll out the owner for changes to its static channel/target configuration. Never roll out more than one owner writer.
|
|
69
|
+
|
|
70
|
+
The gateway uses two replicas and a disruption budget. PocketBase and PostgreSQL use one replica with Recreate and single-pod persistent access. This prevents intentionally scheduled concurrent writers, not arbitrary storage corruption or all split-brain scenarios. Planned owner/PostgreSQL restarts interrupt their availability. Add partitioning or separately designed database failover when required; do not simply increase replicas.
|
|
71
|
+
|
|
72
|
+
```mermaid
|
|
73
|
+
flowchart LR
|
|
74
|
+
clients[Shop clients] --> ingress[TLS ingress]
|
|
75
|
+
ingress --> gateway[Store-bound gateways]
|
|
76
|
+
gateway --> owner[One PocketBase writer per partition]
|
|
77
|
+
owner --> vendure[Vendure commerce service]
|
|
78
|
+
vendure --> postgres[PostgreSQL]
|
|
79
|
+
owner --> backups[Configured backup target]
|
|
80
|
+
operators[Private administration] --> owner
|
|
81
|
+
operators --> vendure
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
NetworkPolicy permits gateway→owner:8090, owner→Vendure:3000 and Vendure→PostgreSQL:5432, plus cluster DNS. Public ingress is permitted only from namespaces labeled `x47-ingress=true` to gateway:8095. Egress CIDRs allow owner/Vendure HTTPS to configured remote storage/payment services. An empty CIDR list disables that external HTTPS access. CIDRs do not follow changing DNS answers: maintain provider ranges or use an egress gateway/CNI FQDN policy. Node-local DNS and controller-specific health checks may require cluster-specific policies. There is no automatically public Vendure webhook endpoint; add a narrowly scoped, authenticated/verified application route for your payment provider.
|
|
85
|
+
|
|
86
|
+
## Backup and restore operations
|
|
87
|
+
|
|
88
|
+
1. In native PocketBase **Hosting**, create a paused policy and run a snapshot. Check its completed record and destination object. Enable the schedule only after verifying a recovery copy.
|
|
89
|
+
2. Keep encryption keys separately from snapshots. A key on the same failed owner is not a recovery plan.
|
|
90
|
+
3. Restore a copy in a disposable owner using the matching application/migrations. For alternate targets, securely retrieve the snapshot and upload/import it through the base add-on's supported backup recovery flow; the native PocketBase backup browser lists only its configured default target.
|
|
91
|
+
4. Verify records, file attachments, store memberships, policies and authentication after restoration. Review restored schedules before reconnecting remote destinations to avoid unexpected retention deletions. Rotate copied credentials in a recovery environment.
|
|
92
|
+
5. Back up PostgreSQL independently with your chosen PostgreSQL backup/WAL tooling and test its recovery. A PocketBase snapshot is not a cross-database atomic commerce snapshot. Reconcile application state across recovery points.
|
|
93
|
+
|
|
94
|
+
## Provider interfaces and verification boundary
|
|
95
|
+
|
|
96
|
+
DNS plans support explicit Cloudflare API creation and Route 53 `CREATE` batches. Cloudflare requires zone-scoped DNS Read/Edit; AWS should limit `route53:ChangeResourceRecordSets` to the intended hosted zone/record names. Provider apply was not exercised against a real account in this implementation. S3 plaintext rejection, local snapshot retention, application authorization, browser flows and generated manifest validation were exercised; actual S3 delivery, public ACME, cluster admission/scheduling and restore drills still require deployment-specific verification.
|
|
97
|
+
|
|
98
|
+
Primary references: [Kubernetes network policies](https://kubernetes.io/docs/concepts/services-networking/network-policies/), [single-pod volume access](https://kubernetes.io/docs/tasks/administer-cluster/change-pv-access-mode-readwriteoncepod/), [official PostgreSQL image](https://hub.docker.com/_/postgres), [Cloudflare DNS create](https://developers.cloudflare.com/api/resources/dns/subresources/records/methods/create/), [AWS Route 53 CLI](https://docs.aws.amazon.com/cli/latest/reference/route53/change-resource-record-sets.html), [Vercel Go request handlers](https://vercel.com/docs/functions/runtimes/go).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"targets":[{"key":"owner-default","kind":"native"},{"key":"local-archive","kind":"local","path":"/data/backups/archives"},{"key":"remote-s3","kind":"s3","bucket":"replace-me","region":"auto","endpoint":"https://account-id.r2.cloudflarestorage.com","accessKeyEnv":"BACKUP_ACCESS_KEY","secretEnv":"BACKUP_SECRET_KEY","pathStyle":false}]}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
package hosting
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"os"
|
|
5
|
+
"path/filepath"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestLocalTargetCannotIncludeBackupsInNextSnapshot(t *testing.T) {
|
|
10
|
+
root := t.TempDir()
|
|
11
|
+
data := filepath.Join(root, "pb_data")
|
|
12
|
+
if err := os.MkdirAll(filepath.Join(data, "backups"), 0700); err != nil {
|
|
13
|
+
t.Fatal(err)
|
|
14
|
+
}
|
|
15
|
+
alias := filepath.Join(root, "alias")
|
|
16
|
+
if err := os.Symlink(data, alias); err != nil {
|
|
17
|
+
t.Fatal(err)
|
|
18
|
+
}
|
|
19
|
+
for _, path := range []string{data, filepath.Join(data, "archives"), filepath.Join(alias, "archives", "new")} {
|
|
20
|
+
if err := validateLocalTarget(data, path); err == nil {
|
|
21
|
+
t.Errorf("accepted recursive target %s", path)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
for _, path := range []string{filepath.Join(root, "outside"), filepath.Join(data, "backups", "archives")} {
|
|
25
|
+
if err := validateLocalTarget(data, path); err != nil {
|
|
26
|
+
t.Fatal(err)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|