Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0e15f02
s3keys: export ParseBlobKey for offline blob-key consumers
bootjp Apr 30, 2026
554dd42
backup: S3 encoder for buckets, objects, and blob reassembly (Phase 0a)
bootjp Apr 30, 2026
b3f2842
Merge remote-tracking branch 'origin/feat/backup-phase0a-dynamodb' in…
bootjp Apr 30, 2026
19ae328
backup: address review on S3 encoder (PR #718)
bootjp Apr 30, 2026
33bff13
Merge remote-tracking branch 'origin/feat/backup-phase0a-dynamodb' in…
bootjp Apr 30, 2026
92ee22e
backup: address codex review on S3 encoder (PR #718, round 2)
bootjp Apr 30, 2026
2094e3e
Merge remote-tracking branch 'origin/feat/backup-phase0a-dynamodb' in…
bootjp Apr 30, 2026
2c44292
backup: handle file-vs-directory S3 key collisions (PR #718, round 3)
bootjp Apr 30, 2026
844fd49
Merge remote-tracking branch 'origin/feat/backup-phase0a-keymap-manif…
bootjp Apr 30, 2026
ba33df8
backup: validate chunk completeness + reject empty slash segments (PR…
bootjp Apr 30, 2026
2f87b84
Merge remote-tracking branch 'origin/feat/backup-phase0a-keymap-manif…
bootjp Apr 30, 2026
2febd42
backup: reject leading-slash S3 object keys (PR #718, round 5)
bootjp Apr 30, 2026
09c2a0e
Merge remote-tracking branch 'origin/feat/backup-phase0a-redis-simple…
bootjp Apr 30, 2026
a4fce85
backup: reject backslashes in S3 object keys (PR #718, round 6)
bootjp Apr 30, 2026
00819af
Merge remote-tracking branch 'origin/feat/backup-phase0a-keymap-manif…
bootjp Apr 30, 2026
6dd4575
Merge remote-tracking branch 'origin/feat/backup-phase0a-redis-simple…
bootjp Apr 30, 2026
1fa9345
Merge remote-tracking branch 'origin/feat/backup-phase0a-redis-simple…
bootjp Apr 30, 2026
2a154af
Merge remote-tracking branch 'origin/feat/backup-phase0a-keymap-manif…
bootjp Apr 30, 2026
0f390b8
Merge remote-tracking branch 'origin/feat/backup-phase0a-redis-simple…
bootjp Apr 30, 2026
19d33a6
Merge remote-tracking branch 'origin/feat/backup-phase0a-sqs' into fe…
bootjp Apr 30, 2026
7a40ae8
Merge remote-tracking branch 'origin/feat/backup-phase0a-keymap-manif…
bootjp Apr 30, 2026
ab38eb0
Merge remote-tracking branch 'origin/feat/backup-phase0a-redis-simple…
bootjp Apr 30, 2026
46cb56f
backup: close S3 KEYMAP fd + use openSidecarFile (PR #718, round 7)
bootjp Apr 30, 2026
b196bf7
Merge remote-tracking branch 'origin/feat/backup-phase0a-redis-simple…
bootjp Apr 30, 2026
1dc6884
Merge remote-tracking branch 'origin/feat/backup-phase0a-sqs' into fe…
bootjp Apr 30, 2026
402f6e5
Merge remote-tracking branch 'origin/feat/backup-phase0a-sqs' into fe…
bootjp Apr 30, 2026
b65c06b
Merge remote-tracking branch 'origin/feat/backup-phase0a-dynamodb' in…
bootjp Apr 30, 2026
90d33fe
backup: rename-target collision check + populate last_modified (PR #7…
bootjp Apr 30, 2026
6395937
Merge remote-tracking branch 'origin/feat/backup-phase0a-dynamodb' in…
bootjp Apr 30, 2026
9a63e32
Merge remote-tracking branch 'origin/feat/backup-phase0a-sqs' into fe…
bootjp Apr 30, 2026
d016ea7
backup: refuse dot-segment scratch paths in HandleBlob (PR #718, roun…
bootjp Apr 30, 2026
8881bf3
Merge remote-tracking branch 'origin/feat/backup-phase0a-redis-simple…
bootjp Apr 30, 2026
e91f086
Merge remote-tracking branch 'origin/feat/backup-phase0a-sqs' into fe…
bootjp Apr 30, 2026
4505df3
backup: set-based chunk completeness check (PR #718, round 10)
bootjp Apr 30, 2026
0e6a140
Merge remote-tracking branch 'origin/feat/backup-phase0a-dynamodb' in…
bootjp Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 33 additions & 14 deletions internal/backup/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,19 +132,24 @@ type Exclusions struct {
// Manifest is the on-disk MANIFEST.json structure. Field tags match the
// spec in docs/design/2026_04_29_proposed_snapshot_logical_decoder.md.
type Manifest struct {
FormatVersion uint32 `json:"format_version"`
Phase string `json:"phase"`
ElastickvVersion string `json:"elastickv_version,omitempty"`
ClusterID string `json:"cluster_id,omitempty"`
SnapshotIndex uint64 `json:"snapshot_index,omitempty"`
LastCommitTS uint64 `json:"last_commit_ts,omitempty"`
WallTimeISO string `json:"wall_time_iso"`
Source *Source `json:"source,omitempty"`
Live *Live `json:"live,omitempty"`
Adapters Adapters `json:"adapters"`
Exclusions Exclusions `json:"exclusions"`
ChecksumAlgorithm string `json:"checksum_algorithm"`
ChecksumFormat string `json:"checksum_format"`
FormatVersion uint32 `json:"format_version"`
Phase string `json:"phase"`
ElastickvVersion string `json:"elastickv_version,omitempty"`
ClusterID string `json:"cluster_id,omitempty"`
SnapshotIndex uint64 `json:"snapshot_index,omitempty"`
LastCommitTS uint64 `json:"last_commit_ts,omitempty"`
WallTimeISO string `json:"wall_time_iso"`
Source *Source `json:"source,omitempty"`
Live *Live `json:"live,omitempty"`
// Adapters and Exclusions are pointer types so ReadManifest can
// distinguish "section omitted entirely" (a corrupted or
// truncated dump that should fail validation) from "section
// present but populated with default values" (legitimate
// scope-everything-excluded). Codex P2 #146 (round 3).
Adapters *Adapters `json:"adapters"`
Exclusions *Exclusions `json:"exclusions"`
ChecksumAlgorithm string `json:"checksum_algorithm"`
ChecksumFormat string `json:"checksum_format"`

EncodedFilenameCharset string `json:"encoded_filename_charset"`
KeySegmentMaxBytes uint32 `json:"key_segment_max_bytes"`
Expand All @@ -163,12 +168,17 @@ var ErrInvalidManifest = errors.New("backup: manifest invalid")

// NewPhase0SnapshotManifest seeds a manifest with the Phase 0a defaults.
// Callers fill in scope (Adapters), Source/wall time and exclusions before
// passing it to WriteManifest.
// passing it to WriteManifest. Adapters and Exclusions are seeded to
// non-nil zero values so the resulting manifest passes the
// "section-present" validation; callers populating individual scopes
// reach in via the now-non-nil pointer.
func NewPhase0SnapshotManifest(now time.Time) Manifest {
return Manifest{
FormatVersion: CurrentFormatVersion,
Phase: PhasePhase0SnapshotDecode,
WallTimeISO: now.UTC().Format(time.RFC3339Nano),
Adapters: &Adapters{},
Exclusions: &Exclusions{},
ChecksumAlgorithm: ChecksumAlgorithmSHA256,
ChecksumFormat: ChecksumFormatSha256sum,
EncodedFilenameCharset: EncodedFilenameCharsetRFC3986,
Expand Down Expand Up @@ -266,6 +276,15 @@ func (m Manifest) validateRequiredFields() error {
if _, err := time.Parse(time.RFC3339Nano, m.WallTimeISO); err != nil {
return errors.Wrapf(ErrInvalidManifest, "wall_time_iso unparseable: %v", err)
}
// Adapters and Exclusions are required structural sections.
// A manifest that omits either is treated as truncated/corrupted
// (Codex P2 #146 round 3).
if m.Adapters == nil {
return errors.Wrap(ErrInvalidManifest, "adapters section missing")
}
if m.Exclusions == nil {
return errors.Wrap(ErrInvalidManifest, "exclusions section missing")
}
return nil
}

Expand Down
50 changes: 48 additions & 2 deletions internal/backup/manifest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,13 @@ func TestManifest_Phase0RoundTrip(t *testing.T) {
m.SnapshotIndex = 18432021
m.LastCommitTS = 4517352099840000
m.Source = &Source{FSMPath: "/data/fsm-snap/0000000000000064.fsm", FSMCRC32C: "deadbeef"}
m.Adapters = Adapters{
m.Adapters = &Adapters{
DynamoDB: &Adapter{Tables: []string{"orders", "users"}},
S3: &Adapter{Buckets: []string{"photos"}},
Redis: &Adapter{Databases: []uint32{0}},
SQS: &Adapter{Queues: []string{"orders-fifo.fifo"}},
}
m.Exclusions = Exclusions{} // all defaults
m.Exclusions = &Exclusions{} // all defaults

var buf bytes.Buffer
if err := WriteManifest(&buf, m); err != nil {
Expand Down Expand Up @@ -311,6 +311,52 @@ func TestAdaptersStruct_NilVsEmptyDistinguishedOnDisk(t *testing.T) {
}
}

func TestReadManifest_RejectsMissingAdapters(t *testing.T) {
t.Parallel()
// Adapters section omitted from the JSON entirely — Codex P2
// #146 round 3. With Adapters as a pointer the omission decodes
// as nil; validation must surface ErrInvalidManifest rather than
// treat an empty zero-value section as valid.
body := `{
"format_version": 1,
"phase": "phase0-snapshot-decode",
"wall_time_iso": "2026-04-29T00:00:00Z",
"exclusions": {"include_incomplete_uploads":false,"include_orphans":false,"preserve_sqs_visibility":false,"include_sqs_side_records":false},
"checksum_algorithm": "sha256",
"checksum_format": "sha256sum",
"encoded_filename_charset": "rfc3986-unreserved-plus-percent",
"key_segment_max_bytes": 240,
"s3_meta_suffix": ".elastickv-meta.json",
"s3_collision_strategy": "leaf-data-suffix",
"dynamodb_layout": "per-item"
}`
_, err := ReadManifest(strings.NewReader(body))
if !errors.Is(err, ErrInvalidManifest) {
t.Fatalf("err=%v want ErrInvalidManifest for missing adapters", err)
}
}

func TestReadManifest_RejectsMissingExclusions(t *testing.T) {
t.Parallel()
body := `{
"format_version": 1,
"phase": "phase0-snapshot-decode",
"wall_time_iso": "2026-04-29T00:00:00Z",
"adapters": {},
"checksum_algorithm": "sha256",
"checksum_format": "sha256sum",
"encoded_filename_charset": "rfc3986-unreserved-plus-percent",
"key_segment_max_bytes": 240,
"s3_meta_suffix": ".elastickv-meta.json",
"s3_collision_strategy": "leaf-data-suffix",
"dynamodb_layout": "per-item"
}`
_, err := ReadManifest(strings.NewReader(body))
if !errors.Is(err, ErrInvalidManifest) {
t.Fatalf("err=%v want ErrInvalidManifest for missing exclusions", err)
}
}

func TestWriteManifest_ProducesPrettyJSON(t *testing.T) {
t.Parallel()
m := NewPhase0SnapshotManifest(time.Now())
Expand Down
Loading
Loading