feat(recovery): define durable session identities
- commit
- dfc95bea73495fad68b9284c0d5dc93c68c0dc2e
- author
- brice · · 2026-07-25 19:23 UTC
- parent
- 623e7c1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +264 -0
A internal/domain/recovery.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/hex" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "strings" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // IncarnationID permanently distinguishes uses of the same session name. | |
| 12 | + | type IncarnationID [16]byte | |
| 13 | + | ||
| 14 | + | // RecoveryState describes whether and how a named session can be restored. | |
| 15 | + | type RecoveryState uint8 | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | RecoveryFresh RecoveryState = iota + 1 | |
| 19 | + | RecoveryHealthy | |
| 20 | + | RecoveryDegraded | |
| 21 | + | RecoveryDeleting | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // CheckpointRef identifies one immutable snapshot manifest. | |
| 25 | + | type CheckpointRef struct { | |
| 26 | + | Generation uint64 | |
| 27 | + | ManifestDigest [32]byte | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // CatalogueRecord is the durable source of truth for one named session. | |
| 31 | + | type CatalogueRecord struct { | |
| 32 | + | Name string | |
| 33 | + | IncarnationID IncarnationID | |
| 34 | + | Cwd string | |
| 35 | + | CreatedAt int64 | |
| 36 | + | UpdatedAt int64 | |
| 37 | + | LastUsedSeq uint64 | |
| 38 | + | TabNames []string | |
| 39 | + | RecoveryState RecoveryState | |
| 40 | + | Committed *CheckpointRef | |
| 41 | + | Fallbacks [2]*CheckpointRef | |
| 42 | + | DegradedReason string | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // DiscardIntent records a requested replacement of uncertain persisted state. | |
| 46 | + | type DiscardIntent struct { | |
| 47 | + | OldRecord CatalogueRecord | |
| 48 | + | OldIncarnation IncarnationID | |
| 49 | + | NewIncarnation IncarnationID | |
| 50 | + | SessionName string | |
| 51 | + | Reason string | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // QuarantineDescriptor records persisted state retained during replacement. | |
| 55 | + | type QuarantineDescriptor struct { | |
| 56 | + | OldRecord CatalogueRecord | |
| 57 | + | OldIncarnation IncarnationID | |
| 58 | + | ReplacementIncarnation IncarnationID | |
| 59 | + | SessionName string | |
| 60 | + | Reason string | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // DeletionTombstone identifies exactly one deleted session incarnation. | |
| 64 | + | type DeletionTombstone struct { | |
| 65 | + | Name string | |
| 66 | + | IncarnationID IncarnationID | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Validate rejects incomplete deletion identities. | |
| 70 | + | func (t DeletionTombstone) Validate() error { | |
| 71 | + | if err := ValidateSessionName(t.Name); err != nil { | |
| 72 | + | return err | |
| 73 | + | } | |
| 74 | + | if t.IncarnationID == (IncarnationID{}) { | |
| 75 | + | return errors.New("zero deletion incarnation ID") | |
| 76 | + | } | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // NewIncarnationID reads a new non-zero identity from r. | |
| 81 | + | func NewIncarnationID(r io.Reader) (IncarnationID, error) { | |
| 82 | + | var id IncarnationID | |
| 83 | + | if _, err := io.ReadFull(r, id[:]); err != nil { | |
| 84 | + | return id, err | |
| 85 | + | } | |
| 86 | + | if id == (IncarnationID{}) { | |
| 87 | + | return id, errors.New("zero incarnation ID") | |
| 88 | + | } | |
| 89 | + | return id, nil | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | func (id IncarnationID) String() string { | |
| 93 | + | return hex.EncodeToString(id[:]) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // MarshalText emits the canonical lowercase hexadecimal identity. | |
| 97 | + | func (id IncarnationID) MarshalText() ([]byte, error) { | |
| 98 | + | if id == (IncarnationID{}) { | |
| 99 | + | return nil, errors.New("zero incarnation ID") | |
| 100 | + | } | |
| 101 | + | return []byte(id.String()), nil | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // UnmarshalText accepts only a canonical lowercase non-zero identity. | |
| 105 | + | func (id *IncarnationID) UnmarshalText(text []byte) error { | |
| 106 | + | if len(text) != 32 || strings.ToLower(string(text)) != string(text) { | |
| 107 | + | return errors.New("invalid incarnation ID") | |
| 108 | + | } | |
| 109 | + | var decoded IncarnationID | |
| 110 | + | if _, err := hex.Decode(decoded[:], text); err != nil { | |
| 111 | + | return fmt.Errorf("invalid incarnation ID: %w", err) | |
| 112 | + | } | |
| 113 | + | if decoded == (IncarnationID{}) { | |
| 114 | + | return errors.New("zero incarnation ID") | |
| 115 | + | } | |
| 116 | + | *id = decoded | |
| 117 | + | return nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Validate rejects catalogue states that cannot be restored deterministically. | |
| 121 | + | func (r CatalogueRecord) Validate() error { | |
| 122 | + | if err := ValidateSessionName(r.Name); err != nil { | |
| 123 | + | return err | |
| 124 | + | } | |
| 125 | + | if r.IncarnationID == (IncarnationID{}) { | |
| 126 | + | return errors.New("zero catalogue incarnation ID") | |
| 127 | + | } | |
| 128 | + | ||
| 129 | + | switch r.RecoveryState { | |
| 130 | + | case RecoveryFresh: | |
| 131 | + | if r.Committed != nil || r.Fallbacks[0] != nil || r.Fallbacks[1] != nil { | |
| 132 | + | return errors.New("fresh session has checkpoint references") | |
| 133 | + | } | |
| 134 | + | case RecoveryHealthy: | |
| 135 | + | if r.Committed == nil { | |
| 136 | + | return errors.New("healthy session has no committed checkpoint") | |
| 137 | + | } | |
| 138 | + | case RecoveryDegraded: | |
| 139 | + | if r.Committed == nil { | |
| 140 | + | return errors.New("degraded session has no committed checkpoint") | |
| 141 | + | } | |
| 142 | + | if r.DegradedReason == "" { | |
| 143 | + | return errors.New("degraded session has no reason") | |
| 144 | + | } | |
| 145 | + | case RecoveryDeleting: | |
| 146 | + | // A fresh or checkpointed session may be deleted. | |
| 147 | + | default: | |
| 148 | + | return errors.New("invalid recovery state") | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" { | |
| 152 | + | return errors.New("non-degraded session has degraded reason") | |
| 153 | + | } | |
| 154 | + | if r.Fallbacks[1] != nil && r.Fallbacks[0] == nil { | |
| 155 | + | return errors.New("second fallback populated without first fallback") | |
| 156 | + | } | |
| 157 | + | if r.Committed == nil && (r.Fallbacks[0] != nil || r.Fallbacks[1] != nil) { | |
| 158 | + | return errors.New("fallback populated without committed checkpoint") | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | seen := make(map[uint64]struct{}, 3) | |
| 162 | + | var previous uint64 | |
| 163 | + | for i, ref := range []*CheckpointRef{r.Committed, r.Fallbacks[0], r.Fallbacks[1]} { | |
| 164 | + | if ref == nil { | |
| 165 | + | continue | |
| 166 | + | } | |
| 167 | + | if ref.Generation == 0 { | |
| 168 | + | return errors.New("zero checkpoint generation") | |
| 169 | + | } | |
| 170 | + | if ref.ManifestDigest == ([32]byte{}) { | |
| 171 | + | return errors.New("zero checkpoint manifest digest") | |
| 172 | + | } | |
| 173 | + | if _, ok := seen[ref.Generation]; ok { | |
| 174 | + | return errors.New("duplicate checkpoint generation") | |
| 175 | + | } | |
| 176 | + | seen[ref.Generation] = struct{}{} | |
| 177 | + | if i > 0 && previous <= ref.Generation { | |
| 178 | + | return errors.New("checkpoint generations are not newest to oldest") | |
| 179 | + | } | |
| 180 | + | previous = ref.Generation | |
| 181 | + | } | |
| 182 | + | return nil | |
| 183 | + | } | |
| @@ -0,0 +1,183 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/hex" | |||
| 5 | + | "errors" | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "strings" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // IncarnationID permanently distinguishes uses of the same session name. | |||
| 12 | + | type IncarnationID [16]byte | |||
| 13 | + | ||||
| 14 | + | // RecoveryState describes whether and how a named session can be restored. | |||
| 15 | + | type RecoveryState uint8 | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | RecoveryFresh RecoveryState = iota + 1 | |||
| 19 | + | RecoveryHealthy | |||
| 20 | + | RecoveryDegraded | |||
| 21 | + | RecoveryDeleting | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // CheckpointRef identifies one immutable snapshot manifest. | |||
| 25 | + | type CheckpointRef struct { | |||
| 26 | + | Generation uint64 | |||
| 27 | + | ManifestDigest [32]byte | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | // CatalogueRecord is the durable source of truth for one named session. | |||
| 31 | + | type CatalogueRecord struct { | |||
| 32 | + | Name string | |||
| 33 | + | IncarnationID IncarnationID | |||
| 34 | + | Cwd string | |||
| 35 | + | CreatedAt int64 | |||
| 36 | + | UpdatedAt int64 | |||
| 37 | + | LastUsedSeq uint64 | |||
| 38 | + | TabNames []string | |||
| 39 | + | RecoveryState RecoveryState | |||
| 40 | + | Committed *CheckpointRef | |||
| 41 | + | Fallbacks [2]*CheckpointRef | |||
| 42 | + | DegradedReason string | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // DiscardIntent records a requested replacement of uncertain persisted state. | |||
| 46 | + | type DiscardIntent struct { | |||
| 47 | + | OldRecord CatalogueRecord | |||
| 48 | + | OldIncarnation IncarnationID | |||
| 49 | + | NewIncarnation IncarnationID | |||
| 50 | + | SessionName string | |||
| 51 | + | Reason string | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // QuarantineDescriptor records persisted state retained during replacement. | |||
| 55 | + | type QuarantineDescriptor struct { | |||
| 56 | + | OldRecord CatalogueRecord | |||
| 57 | + | OldIncarnation IncarnationID | |||
| 58 | + | ReplacementIncarnation IncarnationID | |||
| 59 | + | SessionName string | |||
| 60 | + | Reason string | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // DeletionTombstone identifies exactly one deleted session incarnation. | |||
| 64 | + | type DeletionTombstone struct { | |||
| 65 | + | Name string | |||
| 66 | + | IncarnationID IncarnationID | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Validate rejects incomplete deletion identities. | |||
| 70 | + | func (t DeletionTombstone) Validate() error { | |||
| 71 | + | if err := ValidateSessionName(t.Name); err != nil { | |||
| 72 | + | return err | |||
| 73 | + | } | |||
| 74 | + | if t.IncarnationID == (IncarnationID{}) { | |||
| 75 | + | return errors.New("zero deletion incarnation ID") | |||
| 76 | + | } | |||
| 77 | + | return nil | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // NewIncarnationID reads a new non-zero identity from r. | |||
| 81 | + | func NewIncarnationID(r io.Reader) (IncarnationID, error) { | |||
| 82 | + | var id IncarnationID | |||
| 83 | + | if _, err := io.ReadFull(r, id[:]); err != nil { | |||
| 84 | + | return id, err | |||
| 85 | + | } | |||
| 86 | + | if id == (IncarnationID{}) { | |||
| 87 | + | return id, errors.New("zero incarnation ID") | |||
| 88 | + | } | |||
| 89 | + | return id, nil | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | func (id IncarnationID) String() string { | |||
| 93 | + | return hex.EncodeToString(id[:]) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // MarshalText emits the canonical lowercase hexadecimal identity. | |||
| 97 | + | func (id IncarnationID) MarshalText() ([]byte, error) { | |||
| 98 | + | if id == (IncarnationID{}) { | |||
| 99 | + | return nil, errors.New("zero incarnation ID") | |||
| 100 | + | } | |||
| 101 | + | return []byte(id.String()), nil | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | // UnmarshalText accepts only a canonical lowercase non-zero identity. | |||
| 105 | + | func (id *IncarnationID) UnmarshalText(text []byte) error { | |||
| 106 | + | if len(text) != 32 || strings.ToLower(string(text)) != string(text) { | |||
| 107 | + | return errors.New("invalid incarnation ID") | |||
| 108 | + | } | |||
| 109 | + | var decoded IncarnationID | |||
| 110 | + | if _, err := hex.Decode(decoded[:], text); err != nil { | |||
| 111 | + | return fmt.Errorf("invalid incarnation ID: %w", err) | |||
| 112 | + | } | |||
| 113 | + | if decoded == (IncarnationID{}) { | |||
| 114 | + | return errors.New("zero incarnation ID") | |||
| 115 | + | } | |||
| 116 | + | *id = decoded | |||
| 117 | + | return nil | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | // Validate rejects catalogue states that cannot be restored deterministically. | |||
| 121 | + | func (r CatalogueRecord) Validate() error { | |||
| 122 | + | if err := ValidateSessionName(r.Name); err != nil { | |||
| 123 | + | return err | |||
| 124 | + | } | |||
| 125 | + | if r.IncarnationID == (IncarnationID{}) { | |||
| 126 | + | return errors.New("zero catalogue incarnation ID") | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | switch r.RecoveryState { | |||
| 130 | + | case RecoveryFresh: | |||
| 131 | + | if r.Committed != nil || r.Fallbacks[0] != nil || r.Fallbacks[1] != nil { | |||
| 132 | + | return errors.New("fresh session has checkpoint references") | |||
| 133 | + | } | |||
| 134 | + | case RecoveryHealthy: | |||
| 135 | + | if r.Committed == nil { | |||
| 136 | + | return errors.New("healthy session has no committed checkpoint") | |||
| 137 | + | } | |||
| 138 | + | case RecoveryDegraded: | |||
| 139 | + | if r.Committed == nil { | |||
| 140 | + | return errors.New("degraded session has no committed checkpoint") | |||
| 141 | + | } | |||
| 142 | + | if r.DegradedReason == "" { | |||
| 143 | + | return errors.New("degraded session has no reason") | |||
| 144 | + | } | |||
| 145 | + | case RecoveryDeleting: | |||
| 146 | + | // A fresh or checkpointed session may be deleted. | |||
| 147 | + | default: | |||
| 148 | + | return errors.New("invalid recovery state") | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | if r.RecoveryState != RecoveryDegraded && r.DegradedReason != "" { | |||
| 152 | + | return errors.New("non-degraded session has degraded reason") | |||
| 153 | + | } | |||
| 154 | + | if r.Fallbacks[1] != nil && r.Fallbacks[0] == nil { | |||
| 155 | + | return errors.New("second fallback populated without first fallback") | |||
| 156 | + | } | |||
| 157 | + | if r.Committed == nil && (r.Fallbacks[0] != nil || r.Fallbacks[1] != nil) { | |||
| 158 | + | return errors.New("fallback populated without committed checkpoint") | |||
| 159 | + | } | |||
| 160 | + | ||||
| 161 | + | seen := make(map[uint64]struct{}, 3) | |||
| 162 | + | var previous uint64 | |||
| 163 | + | for i, ref := range []*CheckpointRef{r.Committed, r.Fallbacks[0], r.Fallbacks[1]} { | |||
| 164 | + | if ref == nil { | |||
| 165 | + | continue | |||
| 166 | + | } | |||
| 167 | + | if ref.Generation == 0 { | |||
| 168 | + | return errors.New("zero checkpoint generation") | |||
| 169 | + | } | |||
| 170 | + | if ref.ManifestDigest == ([32]byte{}) { | |||
| 171 | + | return errors.New("zero checkpoint manifest digest") | |||
| 172 | + | } | |||
| 173 | + | if _, ok := seen[ref.Generation]; ok { | |||
| 174 | + | return errors.New("duplicate checkpoint generation") | |||
| 175 | + | } | |||
| 176 | + | seen[ref.Generation] = struct{}{} | |||
| 177 | + | if i > 0 && previous <= ref.Generation { | |||
| 178 | + | return errors.New("checkpoint generations are not newest to oldest") | |||
| 179 | + | } | |||
| 180 | + | previous = ref.Generation | |||
| 181 | + | } | |||
| 182 | + | return nil | |||
| 183 | + | } | |||
A internal/domain/recovery_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestNewIncarnationID(t *testing.T) { | |
| 11 | + | id, err := NewIncarnationID(rand.Reader) | |
| 12 | + | require.NoError(t, err) | |
| 13 | + | require.NotEqual(t, IncarnationID{}, id) | |
| 14 | + | require.Len(t, id.String(), 32) | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func TestIncarnationIDText(t *testing.T) { | |
| 18 | + | id := IncarnationID{0: 0xab, 15: 0xcd} | |
| 19 | + | text, err := id.MarshalText() | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | require.Equal(t, "ab0000000000000000000000000000cd", string(text)) | |
| 22 | + | var decoded IncarnationID | |
| 23 | + | require.NoError(t, decoded.UnmarshalText(text)) | |
| 24 | + | require.Equal(t, id, decoded) | |
| 25 | + | require.Error(t, decoded.UnmarshalText([]byte("AB"))) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestCatalogueRecordValidate(t *testing.T) { | |
| 29 | + | valid := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh} | |
| 30 | + | require.NoError(t, valid.Validate()) | |
| 31 | + | valid.RecoveryState = RecoveryHealthy | |
| 32 | + | valid.Committed = &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}} | |
| 33 | + | require.NoError(t, valid.Validate()) | |
| 34 | + | valid.Fallbacks[0] = &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{2}} | |
| 35 | + | require.Error(t, valid.Validate(), "duplicate generations are malformed") | |
| 36 | + | ||
| 37 | + | validRef := &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}} | |
| 38 | + | tests := []struct { | |
| 39 | + | name string | |
| 40 | + | record CatalogueRecord | |
| 41 | + | }{ | |
| 42 | + | {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}}, | |
| 43 | + | {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}}, | |
| 44 | + | {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}}, | |
| 45 | + | {name: "fresh with committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh, Committed: validRef}}, | |
| 46 | + | {name: "healthy without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy}}, | |
| 47 | + | {name: "degraded without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, DegradedReason: "missing manifest"}}, | |
| 48 | + | {name: "degraded without reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, Committed: validRef}}, | |
| 49 | + | {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}}, | |
| 50 | + | {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}}, | |
| 51 | + | {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}}, | |
| 52 | + | {name: "second fallback without first", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, Fallbacks: [2]*CheckpointRef{nil, {Generation: 6, ManifestDigest: [32]byte{2}}}}}, | |
| 53 | + | } | |
| 54 | + | for _, tt := range tests { | |
| 55 | + | t.Run(tt.name, func(t *testing.T) { | |
| 56 | + | require.Error(t, tt.record.Validate()) | |
| 57 | + | }) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestDeletionTombstoneValidate(t *testing.T) { | |
| 62 | + | tests := []struct { | |
| 63 | + | name string | |
| 64 | + | tombstone DeletionTombstone | |
| 65 | + | wantErr bool | |
| 66 | + | }{ | |
| 67 | + | {name: "valid", tombstone: DeletionTombstone{Name: "work", IncarnationID: IncarnationID{1}}}, | |
| 68 | + | {name: "invalid name", tombstone: DeletionTombstone{Name: "bad/name", IncarnationID: IncarnationID{1}}, wantErr: true}, | |
| 69 | + | {name: "zero incarnation", tombstone: DeletionTombstone{Name: "work"}, wantErr: true}, | |
| 70 | + | } | |
| 71 | + | for _, tt := range tests { | |
| 72 | + | t.Run(tt.name, func(t *testing.T) { | |
| 73 | + | err := tt.tombstone.Validate() | |
| 74 | + | if tt.wantErr { | |
| 75 | + | require.Error(t, err) | |
| 76 | + | return | |
| 77 | + | } | |
| 78 | + | require.NoError(t, err) | |
| 79 | + | }) | |
| 80 | + | } | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/rand" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestNewIncarnationID(t *testing.T) { | |||
| 11 | + | id, err := NewIncarnationID(rand.Reader) | |||
| 12 | + | require.NoError(t, err) | |||
| 13 | + | require.NotEqual(t, IncarnationID{}, id) | |||
| 14 | + | require.Len(t, id.String(), 32) | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func TestIncarnationIDText(t *testing.T) { | |||
| 18 | + | id := IncarnationID{0: 0xab, 15: 0xcd} | |||
| 19 | + | text, err := id.MarshalText() | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | require.Equal(t, "ab0000000000000000000000000000cd", string(text)) | |||
| 22 | + | var decoded IncarnationID | |||
| 23 | + | require.NoError(t, decoded.UnmarshalText(text)) | |||
| 24 | + | require.Equal(t, id, decoded) | |||
| 25 | + | require.Error(t, decoded.UnmarshalText([]byte("AB"))) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestCatalogueRecordValidate(t *testing.T) { | |||
| 29 | + | valid := CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh} | |||
| 30 | + | require.NoError(t, valid.Validate()) | |||
| 31 | + | valid.RecoveryState = RecoveryHealthy | |||
| 32 | + | valid.Committed = &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}} | |||
| 33 | + | require.NoError(t, valid.Validate()) | |||
| 34 | + | valid.Fallbacks[0] = &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{2}} | |||
| 35 | + | require.Error(t, valid.Validate(), "duplicate generations are malformed") | |||
| 36 | + | ||||
| 37 | + | validRef := &CheckpointRef{Generation: 7, ManifestDigest: [32]byte{1}} | |||
| 38 | + | tests := []struct { | |||
| 39 | + | name string | |||
| 40 | + | record CatalogueRecord | |||
| 41 | + | }{ | |||
| 42 | + | {name: "invalid session name", record: CatalogueRecord{Name: "bad/name", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh}}, | |||
| 43 | + | {name: "zero incarnation", record: CatalogueRecord{Name: "work", RecoveryState: RecoveryFresh}}, | |||
| 44 | + | {name: "unknown recovery state", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryState(99)}}, | |||
| 45 | + | {name: "fresh with committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryFresh, Committed: validRef}}, | |||
| 46 | + | {name: "healthy without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy}}, | |||
| 47 | + | {name: "degraded without committed checkpoint", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, DegradedReason: "missing manifest"}}, | |||
| 48 | + | {name: "degraded without reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryDegraded, Committed: validRef}}, | |||
| 49 | + | {name: "healthy with degraded reason", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, DegradedReason: "missing manifest"}}, | |||
| 50 | + | {name: "zero checkpoint generation", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{ManifestDigest: [32]byte{1}}}}, | |||
| 51 | + | {name: "zero checkpoint digest", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: &CheckpointRef{Generation: 7}}}, | |||
| 52 | + | {name: "second fallback without first", record: CatalogueRecord{Name: "work", IncarnationID: IncarnationID{1}, RecoveryState: RecoveryHealthy, Committed: validRef, Fallbacks: [2]*CheckpointRef{nil, {Generation: 6, ManifestDigest: [32]byte{2}}}}}, | |||
| 53 | + | } | |||
| 54 | + | for _, tt := range tests { | |||
| 55 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 56 | + | require.Error(t, tt.record.Validate()) | |||
| 57 | + | }) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestDeletionTombstoneValidate(t *testing.T) { | |||
| 62 | + | tests := []struct { | |||
| 63 | + | name string | |||
| 64 | + | tombstone DeletionTombstone | |||
| 65 | + | wantErr bool | |||
| 66 | + | }{ | |||
| 67 | + | {name: "valid", tombstone: DeletionTombstone{Name: "work", IncarnationID: IncarnationID{1}}}, | |||
| 68 | + | {name: "invalid name", tombstone: DeletionTombstone{Name: "bad/name", IncarnationID: IncarnationID{1}}, wantErr: true}, | |||
| 69 | + | {name: "zero incarnation", tombstone: DeletionTombstone{Name: "work"}, wantErr: true}, | |||
| 70 | + | } | |||
| 71 | + | for _, tt := range tests { | |||
| 72 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 73 | + | err := tt.tombstone.Validate() | |||
| 74 | + | if tt.wantErr { | |||
| 75 | + | require.Error(t, err) | |||
| 76 | + | return | |||
| 77 | + | } | |||
| 78 | + | require.NoError(t, err) | |||
| 79 | + | }) | |||
| 80 | + | } | |||
| 81 | + | } | |||