tinyrelayfile
tinyrelay/internal/replication/backup.go/

internal/replication/backup.go raw
1package replication
2
3import (
4 "bytes"
5 "context"
6 "crypto/sha256"
7 "database/sql"
8 "encoding/base64"
9 "encoding/hex"
10 "encoding/json"
11 "errors"
12 "fmt"
13 "io"
14 "strings"
15 "time"
16
17 "github.com/FelineStateMachine/tinyrelay/internal/event"
18 "github.com/FelineStateMachine/tinyrelay/internal/storage"
19)
20
21// BackupFormat identifies the JSON archive format produced by this package.
22const BackupFormat = "tinyrelay/backup/1"
23
24// BackupArchive is a sealed event and application-state export.
25type BackupArchive struct {
26 Format string `json:"format"`
27 Events []event.Event `json:"events"`
28 State BackupState `json:"state"`
29 SHA256 string `json:"sha256"`
30 Created int64 `json:"created_at"`
31}
32
33// BackupState contains application-owned state and exported objects.
34type BackupState struct {
35 Config json.RawMessage `json:"config,omitempty"`
36 Community json.RawMessage `json:"community,omitempty"`
37 Identity json.RawMessage `json:"identity,omitempty"`
38 Blobs []BackupObject `json:"blobs,omitempty"`
39 Sites []BackupObject `json:"sites,omitempty"`
40 Git []BackupObject `json:"git,omitempty"`
41 GitSQL json.RawMessage `json:"sqlGit,omitempty"`
42 Database []BackupTable `json:"database,omitempty"`
43}
44
45// BackupTable is a named table snapshot represented as JSON rows.
46type BackupTable struct {
47 Name string `json:"name"`
48 Columns []string `json:"columns"`
49 Rows [][]json.RawMessage `json:"rows"`
50}
51
52// BackupObject is an exported named file or blob with an integrity digest.
53type BackupObject struct {
54 Name string `json:"name"`
55 SHA256 string `json:"sha256"`
56 Content []byte `json:"content"`
57 Type string `json:"type,omitempty"`
58 Uploader string `json:"uploader,omitempty"`
59 Uploaded int64 `json:"uploaded,omitempty"`
60}
61
62// BackupProvider snapshots and restores application state associated with a
63// relay database. The provider owns its external resources.
64type BackupProvider interface {
65 Snapshot(ctx context.Context) (BackupState, error)
66 Restore(ctx context.Context, state BackupState) error
67}
68
69// FullBackupProvider can produce a complete archive using its own snapshot
70// coordination.
71type FullBackupProvider interface {
72 BackupProvider
73 SnapshotArchive(ctx context.Context, created int64) (BackupArchive, error)
74}
75
76// RestoreFinalizer runs application cleanup after a successful restore.
77type RestoreFinalizer interface {
78 FinalizeRestore(ctx context.Context) error
79}
80
81func CreateBackup(ctx context.Context, store *storage.Store, now int64) (BackupArchive, error) {
82 rows, err := store.After(ctx, 0, event.Filter{Tags: map[string][]string{}}, storage.QueryOptions{Now: now, Access: storage.Access{All: true}, Limit: 0})
83 if err != nil {
84 return BackupArchive{}, err
85 }
86 archive := BackupArchive{Format: BackupFormat, Created: now, Events: make([]event.Event, 0, len(rows))}
87 for _, row := range rows {
88 archive.Events = append(archive.Events, row.Event)
89 }
90 return sealBackup(archive)
91}
92
93func CreateBackupWithProvider(ctx context.Context, store *storage.Store, provider BackupProvider, now int64) (BackupArchive, error) {
94 if full, ok := provider.(FullBackupProvider); ok {
95 return full.SnapshotArchive(ctx, now)
96 }
97 archive, err := CreateBackup(ctx, store, now)
98 if err != nil || provider == nil {
99 return archive, err
100 }
101 state, err := provider.Snapshot(ctx)
102 if err != nil {
103 return BackupArchive{}, err
104 }
105 archive.State = state
106 return sealBackup(archive)
107}
108
109func SealBackup(archive BackupArchive) (BackupArchive, error) { return sealBackup(archive) }
110
111func WriteBackup(ctx context.Context, store *storage.Store, output io.Writer, now int64) error {
112 archive, err := CreateBackup(ctx, store, now)
113 if err != nil {
114 return err
115 }
116 return json.NewEncoder(output).Encode(archive)
117}
118
119func ReadBackup(input io.Reader) (BackupArchive, error) {
120 var archive BackupArchive
121 decoder := json.NewDecoder(input)
122 if err := decoder.Decode(&archive); err != nil {
123 return archive, fmt.Errorf("replication: decode backup: %w", err)
124 }
125 var trailing any
126 if err := decoder.Decode(&trailing); err != io.EOF {
127 if err == nil {
128 return archive, errors.New("replication: trailing JSON after backup")
129 }
130 return archive, fmt.Errorf("replication: trailing backup data: %w", err)
131 }
132 if archive.Format != BackupFormat {
133 return archive, errors.New("replication: unsupported backup format")
134 }
135 sealed, err := sealBackup(BackupArchive{Format: archive.Format, Events: archive.Events, State: archive.State, Created: archive.Created})
136 if err != nil || sealed.SHA256 != archive.SHA256 {
137 return archive, errors.New("replication: backup integrity check failed")
138 }
139 return archive, nil
140}
141
142// ReadCompatibleBackup accepts both the native archive and bind.ws/relay-backup/1.
143// The legacy shape is normalized before restore so callers have one validation path.
144func ReadCompatibleBackup(input io.Reader) (BackupArchive, error) {
145 data, err := io.ReadAll(input)
146 if err != nil {
147 return BackupArchive{}, err
148 }
149 var envelope struct {
150 Format string `json:"format"`
151 }
152 if err := json.Unmarshal(data, &envelope); err != nil {
153 return BackupArchive{}, fmt.Errorf("replication: decode backup envelope: %w", err)
154 }
155 if envelope.Format == BackupFormat {
156 return ReadBackup(bytes.NewReader(data))
157 }
158 if envelope.Format != "bind.ws/relay-backup/1" {
159 return BackupArchive{}, errors.New("replication: unsupported backup format")
160 }
161 return readBindWSBackup(data)
162}
163
164func readBindWSBackup(data []byte) (BackupArchive, error) {
165 var legacy struct {
166 Format string `json:"format"`
167 Manifest struct {
168 CreatedAt int64 `json:"createdAt"`
169 ArchiveSHA256 string `json:"archiveSha256"`
170 Owner string `json:"owner"`
171 RelayIdentity string `json:"relayIdentity"`
172 Slug string `json:"slug"`
173 } `json:"manifest"`
174 Config json.RawMessage `json:"config"`
175 Events []string `json:"events"`
176 Blobs []struct {
177 SHA256 string `json:"sha256"`
178 Type string `json:"type"`
179 Uploader string `json:"uploader"`
180 Uploaded int64 `json:"uploaded"`
181 Data string `json:"data"`
182 } `json:"blobs"`
183 Git []struct {
184 Key string `json:"key"`
185 SHA256 string `json:"sha256"`
186 Data string `json:"data"`
187 } `json:"git"`
188 SQLGit json.RawMessage `json:"sqlGit"`
189 State json.RawMessage `json:"state"`
190 }
191 if err := json.Unmarshal(data, &legacy); err != nil {
192 return BackupArchive{}, err
193 }
194 if legacy.Manifest.ArchiveSHA256 == "" {
195 return BackupArchive{}, errors.New("replication: legacy archive checksum missing")
196 }
197 canonical, err := blankLegacyArchiveHash(data)
198 if err != nil || !strings.EqualFold(legacy.Manifest.ArchiveSHA256, digest(canonical)) {
199 return BackupArchive{}, errors.New("replication: legacy archive checksum mismatch")
200 }
201 archive := BackupArchive{Format: BackupFormat, Created: legacy.Manifest.CreatedAt, State: BackupState{Config: legacy.Config, GitSQL: append(json.RawMessage(nil), legacy.SQLGit...)}}
202 identity, _ := json.Marshal(map[string]string{"owner": legacy.Manifest.Owner, "relayIdentity": legacy.Manifest.RelayIdentity, "slug": legacy.Manifest.Slug})
203 archive.State.Identity = identity
204 if len(legacy.State) > 0 && string(legacy.State) != "null" {
205 archive.State.Community = append(json.RawMessage(nil), legacy.State...)
206 }
207 for _, raw := range legacy.Events {
208 var item event.Event
209 if err := json.Unmarshal([]byte(raw), &item); err != nil {
210 return BackupArchive{}, fmt.Errorf("replication: legacy event: %w", err)
211 }
212 if err := event.Validate(item); err != nil {
213 return BackupArchive{}, fmt.Errorf("replication: legacy event validation: %w", err)
214 }
215 archive.Events = append(archive.Events, item)
216 }
217 for _, item := range legacy.Blobs {
218 content, err := base64.StdEncoding.DecodeString(item.Data)
219 if err != nil {
220 return BackupArchive{}, err
221 }
222 if !validDigest(item.SHA256, content) {
223 return BackupArchive{}, errors.New("replication: legacy blob checksum mismatch")
224 }
225 archive.State.Blobs = append(archive.State.Blobs, BackupObject{Name: item.SHA256, SHA256: item.SHA256, Content: content, Type: item.Type, Uploader: item.Uploader, Uploaded: item.Uploaded})
226 }
227 for _, item := range legacy.Git {
228 content, err := base64.StdEncoding.DecodeString(item.Data)
229 if err != nil {
230 return BackupArchive{}, err
231 }
232 if item.SHA256 != "" && !validDigest(item.SHA256, content) {
233 return BackupArchive{}, errors.New("replication: legacy git checksum mismatch")
234 }
235 archive.State.Git = append(archive.State.Git, BackupObject{Name: item.Key, SHA256: digest(content), Content: content})
236 }
237 return sealBackup(archive)
238}
239
240func blankLegacyArchiveHash(data []byte) ([]byte, error) {
241 key := []byte(`"archiveSha256"`)
242 start := bytes.Index(data, key)
243 if start < 0 {
244 return nil, errors.New("replication: legacy archive hash field missing")
245 }
246 colon := bytes.IndexByte(data[start+len(key):], ':')
247 if colon < 0 {
248 return nil, errors.New("replication: malformed legacy archive hash")
249 }
250 colon += start + len(key)
251 quote := bytes.IndexByte(data[colon+1:], '"')
252 if quote < 0 {
253 return nil, errors.New("replication: malformed legacy archive hash")
254 }
255 quote += colon + 1
256 end := bytes.IndexByte(data[quote+1:], '"')
257 if end < 0 {
258 return nil, errors.New("replication: malformed legacy archive hash")
259 }
260 end += quote + 1
261 out := make([]byte, 0, len(data))
262 out = append(out, data[:quote+1]...)
263 out = append(out, data[end:]...)
264 return out, nil
265}
266
267func validDigest(expected string, content []byte) bool {
268 return expected != "" && strings.EqualFold(expected, digest(content))
269}
270func digest(content []byte) string { sum := sha256.Sum256(content); return hex.EncodeToString(sum[:]) }
271
272func RestoreBackup(ctx context.Context, store *storage.Store, archive BackupArchive, now int64) (int, error) {
273 return RestoreBackupWithProvider(ctx, store, nil, archive, now)
274}
275
276func RestoreBackupWithProvider(ctx context.Context, store *storage.Store, provider BackupProvider, archive BackupArchive, now int64) (int, error) {
277 if archive.Format != BackupFormat {
278 return 0, errors.New("replication: unsupported backup format")
279 }
280 sealed, err := sealBackup(BackupArchive{Format: archive.Format, Events: archive.Events, State: archive.State, Created: archive.Created})
281 if err != nil {
282 return 0, err
283 }
284 if sealed.SHA256 != archive.SHA256 {
285 return 0, errors.New("replication: backup integrity check failed")
286 }
287 for _, item := range archive.Events {
288 if err := event.Validate(item); err != nil {
289 return 0, fmt.Errorf("replication: restore event validation: %w", err)
290 }
291 }
292 if provider != nil && len(archive.State.Database) > 0 {
293 previous, snapshotErr := provider.Snapshot(ctx)
294 if snapshotErr != nil {
295 return 0, fmt.Errorf("replication: snapshot retained state: %w", snapshotErr)
296 }
297 if err := provider.Restore(ctx, archive.State); err != nil {
298 return 0, fmt.Errorf("replication: restore retained state: %w", err)
299 }
300 if finalizer, ok := provider.(RestoreFinalizer); ok {
301 if err := finalizer.FinalizeRestore(ctx); err != nil {
302 rollbackProvider(ctx, provider, previous)
303 return 0, fmt.Errorf("replication: finalize restore: %w", err)
304 }
305 }
306 return len(archive.Events), nil
307 }
308 var previous BackupState
309 var havePrevious bool
310 if provider != nil {
311 previous, err = provider.Snapshot(ctx)
312 if err != nil {
313 return 0, fmt.Errorf("replication: snapshot retained state: %w", err)
314 }
315 havePrevious = true
316 if err := provider.Restore(ctx, archive.State); err != nil {
317 return 0, fmt.Errorf("replication: restore retained state: %w", err)
318 }
319 }
320 stored := 0
321 inserted := make([]string, 0, len(archive.Events))
322 for _, item := range archive.Events {
323 if err := ctx.Err(); err != nil {
324 rollbackEvents(ctx, store, inserted)
325 if havePrevious {
326 rollbackProvider(ctx, provider, previous)
327 }
328 return stored, err
329 }
330 if _, err := store.Save(ctx, item, storage.SaveOptions{Now: now}); err != nil {
331 if err == storage.ErrDuplicate || err == storage.ErrReplaced {
332 continue
333 }
334 rollbackEvents(ctx, store, inserted)
335 if havePrevious {
336 rollbackProvider(ctx, provider, previous)
337 }
338 return stored, fmt.Errorf("replication: restore event: %w", err)
339 }
340 inserted = append(inserted, item.ID)
341 stored++
342 }
343 if finalizer, ok := provider.(RestoreFinalizer); ok {
344 if err := finalizer.FinalizeRestore(ctx); err != nil {
345 rollbackEvents(ctx, store, inserted)
346 if havePrevious {
347 rollbackProvider(ctx, provider, previous)
348 }
349 return stored, fmt.Errorf("replication: finalize restore: %w", err)
350 }
351 }
352 return stored, nil
353}
354
355func restoreRollbackContext(ctx context.Context) (context.Context, context.CancelFunc) {
356 base := context.WithoutCancel(ctx)
357 return context.WithTimeout(base, 30*time.Second)
358}
359
360func rollbackEvents(ctx context.Context, store *storage.Store, ids []string) {
361 rollback, cancel := restoreRollbackContext(ctx)
362 defer cancel()
363 rollbackRestoreEvents(rollback, store, ids)
364}
365
366func rollbackProvider(ctx context.Context, provider BackupProvider, state BackupState) {
367 if provider == nil {
368 return
369 }
370 rollback, cancel := restoreRollbackContext(ctx)
371 defer cancel()
372 _ = provider.Restore(rollback, state)
373}
374
375func rollbackRestoreEvents(ctx context.Context, store *storage.Store, ids []string) {
376 if len(ids) == 0 || store == nil {
377 return
378 }
379 _ = store.WithTx(ctx, func(tx *sql.Tx) error {
380 marks := make([]string, len(ids))
381 args := make([]any, len(ids))
382 for i, id := range ids {
383 marks[i], args[i] = "?", id
384 }
385 _, err := tx.ExecContext(ctx, `DELETE FROM events WHERE id IN (`+strings.Join(marks, ",")+`)`, args...)
386 return err
387 })
388}
389
390func sealBackup(archive BackupArchive) (BackupArchive, error) {
391 archive.SHA256 = ""
392 raw, err := json.Marshal(archive)
393 if err != nil {
394 return archive, err
395 }
396 sum := sha256.Sum256(raw)
397 archive.SHA256 = hex.EncodeToString(sum[:])
398 return archive, nil
399}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs