1package appstate
2
3import (
4 "context"
5 "fmt"
6
7 bolt "go.etcd.io/bbolt"
8
9 "github.com/bnema/gordon/internal/domain"
10)
11
12// ProtectionSnapshot implements out.PruneProtectionStore: one bbolt
13// View transaction reads every durable fact prune plans against, so no
14// candidate is ever judged against facts from a different moment.
15//
16// Anything that cannot be read completely is reported as an inventory
17// gap instead of being silently omitted: an apparently unprotected
18// snapshot must never be an artifact of a corrupt record.
19func (s *Store) ProtectionSnapshot(ctx context.Context) (*domain.PruneProtectionSnapshot, error) {
20 if err := checkCtx(ctx); err != nil {
21 return nil, err
22 }
23
24 snapshot := &domain.PruneProtectionSnapshot{}
25 if err := s.db.View(func(tx *bolt.Tx) error {
26 return readProtectionLocked(tx, snapshot)
27 }); err != nil {
28 return nil, err
29 }
30
31 snapshot.Roots = dedupeRoots(snapshot.Roots)
32 snapshot.VolumeClaims = dedupeClaims(snapshot.VolumeClaims)
33 return snapshot, nil
34}
35
36// readProtectionLocked fills snapshot from one consistent read view.
37func readProtectionLocked(tx *bolt.Tx, snapshot *domain.PruneProtectionSnapshot) error {
38 if err := readOwnershipHistoryLocked(tx, snapshot); err != nil {
39 return err
40 }
41 // A missing history bucket is a real gap: ownership history is the
42 // only positive proof that a volume was ever Gordon's.
43 if tx.Bucket(bucketOwnershipHistory) == nil {
44 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
45 Source: domain.InventorySourceOwnership,
46 Reason: domain.PruneReasonUnknownInventory,
47 Detail: "ownership history bucket is missing",
48 })
49 }
50
51 apps := tx.Bucket(bucketApps)
52 if apps == nil {
53 return nil
54 }
55 return apps.ForEach(func(name, _ []byte) error {
56 bucket := apps.Bucket(name)
57 if bucket == nil {
58 return nil
59 }
60 // A bucket we cannot decode is reported, never skipped: prune
61 // must not treat an unreadable app as a removed app.
62 readAppProtectionLocked(bucket, string(name), snapshot)
63 return nil
64 })
65}
66
67// readAppProtectionLocked collects desired, active, apply, operation,
68// inhibition, and ownership facts of one app.
69func readAppProtectionLocked(bucket *bolt.Bucket, app string, snapshot *domain.PruneProtectionSnapshot) {
70 record, err := ensureAppRecordLocked(bucket, app, false)
71 if err != nil {
72 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
73 Source: domain.InventorySourceAppState,
74 Reason: domain.PruneReasonUnknownInventory,
75 Detail: fmt.Sprintf("app %s: app record unreadable", app),
76 })
77 // The same failure hides this app's ownership claims, so volume
78 // planning must fail closed too.
79 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
80 Source: domain.InventorySourceOwnership,
81 Reason: domain.PruneReasonUnknownInventory,
82 Detail: fmt.Sprintf("app %s: ownership unreadable with its app record", app),
83 })
84 return
85 }
86 appID := record.ID
87
88 readDesiredProtectionLocked(bucket, app, snapshot)
89 readActiveProtectionLocked(bucket, app, snapshot)
90 readApplyIntentProtectionLocked(bucket, app, snapshot)
91 readOperationProtectionLocked(bucket, app, snapshot)
92 readInhibitionProtectionLocked(bucket, app, snapshot)
93 readAppOwnershipProtectionLocked(bucket, app, appID, snapshot)
94}
95
96func readDesiredProtectionLocked(bucket *bolt.Bucket, app string, snapshot *domain.PruneProtectionSnapshot) {
97 var desired domain.AppDesiredRevision
98 found, err := getRecord(bucket, keyDesired, &desired, "desired")
99 if err != nil {
100 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
101 Source: domain.InventorySourceAppState,
102 Reason: domain.PruneReasonUnknownInventory,
103 Detail: fmt.Sprintf("app %s: desired record unreadable", app),
104 })
105 return
106 }
107 if !found {
108 return
109 }
110 owner := app + "@desired"
111 for _, service := range desired.Spec.Services {
112 snapshot.Roots = append(snapshot.Roots,
113 domain.ImageRefRoots(domain.ProtectionDesiredRevision, service.Image, owner+"."+service.Name)...)
114 }
115}
116
117func readActiveProtectionLocked(bucket *bolt.Bucket, app string, snapshot *domain.PruneProtectionSnapshot) {
118 var active domain.AppActive
119 found, err := getRecord(bucket, keyActive, &active, "active")
120 if err != nil {
121 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
122 Source: domain.InventorySourceAppState,
123 Reason: domain.PruneReasonUnknownInventory,
124 Detail: fmt.Sprintf("app %s: active record unreadable", app),
125 })
126 return
127 }
128 if !found {
129 return
130 }
131 // ACTIVE covers running, stopped, and partially converged services
132 // alike: every entry names an image that must survive.
133 for serviceName, service := range active.Services {
134 owner := app + "@active." + serviceName
135 snapshot.Roots = append(snapshot.Roots, domain.ImageRefRoots(domain.ProtectionActiveService, service.Image, owner)...)
136 if service.Digest != "" {
137 snapshot.Roots = append(snapshot.Roots, domain.ProtectionRoot{
138 Kind: domain.ProtectionActiveService, Ref: service.Digest,
139 Repository: domain.ImageRefRepository(service.Image), Owner: owner,
140 })
141 }
142 if service.Container != "" {
143 snapshot.Roots = append(snapshot.Roots, domain.ProtectionRoot{
144 Kind: domain.ProtectionContainerUse, Ref: service.Container, Owner: owner,
145 })
146 }
147 }
148}
149
150func readApplyIntentProtectionLocked(bucket *bolt.Bucket, app string, snapshot *domain.PruneProtectionSnapshot) {
151 keys, err := listKeys(bucket, subIntents, false)
152 if err != nil {
153 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
154 Source: domain.InventorySourceAppState,
155 Reason: domain.PruneReasonUnknownInventory,
156 Detail: fmt.Sprintf("app %s: apply intents unreadable", app),
157 })
158 return
159 }
160 for _, intentID := range keys {
161 intent, err := loadApplyIntentLocked(bucket, app, intentID)
162 if err != nil {
163 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
164 Source: domain.InventorySourceAppState,
165 Reason: domain.PruneReasonUnknownInventory,
166 Detail: fmt.Sprintf("app %s: apply intent %s unreadable", app, intentID),
167 })
168 continue
169 }
170 // Only staged and committed intents can still materialize; an
171 // applied intent is already covered by ACTIVE.
172 if intent.State != domain.AppIntentStaged && intent.State != domain.AppIntentCommitted {
173 continue
174 }
175 owner := app + "@intent." + intentID
176 for _, service := range intent.Spec.Services {
177 snapshot.Roots = append(snapshot.Roots,
178 domain.ImageRefRoots(domain.ProtectionApplyIntent, service.Image, owner+"."+service.Name)...)
179 }
180 }
181}
182
183func readOperationProtectionLocked(bucket *bolt.Bucket, app string, snapshot *domain.PruneProtectionSnapshot) {
184 keys, err := listKeys(bucket, subOps, false)
185 if err != nil {
186 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
187 Source: domain.InventorySourceOperation,
188 Reason: domain.PruneReasonUnknownInventory,
189 Detail: fmt.Sprintf("app %s: operation journal unreadable", app),
190 })
191 return
192 }
193 ops, err := subBucket(bucket, subOps, false)
194 if err != nil {
195 return
196 }
197 if ops == nil {
198 return
199 }
200 for _, opID := range keys {
201 var op domain.AppOperation
202 found, err := getRecord(ops, []byte(opID), &op, "operation")
203 if err != nil || !found {
204 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
205 Source: domain.InventorySourceOperation,
206 Reason: domain.PruneReasonUnknownInventory,
207 Detail: fmt.Sprintf("app %s: operation %s unreadable", app, opID),
208 })
209 continue
210 }
211 // An operation with no terminal outcome may still be running:
212 // every image it named is protected.
213 if op.Outcome != "" {
214 continue
215 }
216 owner := app + "@op." + opID
217 for _, step := range op.Steps {
218 if step.Digest != "" {
219 snapshot.Roots = append(snapshot.Roots, domain.ProtectionRoot{
220 Kind: domain.ProtectionOperation, Ref: step.Digest,
221 Repository: domain.ImageRefRepository(step.Image), Owner: owner,
222 })
223 }
224 snapshot.Roots = append(snapshot.Roots,
225 domain.ImageRefRoots(domain.ProtectionOperation, step.Image, owner)...)
226 }
227 }
228}
229
230func readInhibitionProtectionLocked(bucket *bolt.Bucket, app string, snapshot *domain.PruneProtectionSnapshot) {
231 var inhibitions []domain.AppRecoveryInhibition
232 found, err := getRecord(bucket, keyInhibitions, &inhibitions, "recovery inhibitions")
233 if err != nil {
234 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
235 Source: domain.InventorySourceAppState,
236 Reason: domain.PruneReasonUnknownInventory,
237 Detail: fmt.Sprintf("app %s: recovery inhibitions unreadable", app),
238 })
239 return
240 }
241 if !found {
242 return
243 }
244 for _, inhibition := range inhibitions {
245 if inhibition.ContainerID == "" {
246 continue
247 }
248 snapshot.Roots = append(snapshot.Roots, domain.ProtectionRoot{
249 Kind: domain.ProtectionRecoveryInhibition,
250 Ref: inhibition.ContainerID,
251 Owner: app + "@" + inhibition.Service,
252 })
253 }
254}
255
256// readAppOwnershipProtectionLocked records the current incarnation's
257// claims. It is the per-app record, which always reflects the live
258// incarnation; historical claims come from the ownership-history
259// bucket.
260func readAppOwnershipProtectionLocked(bucket *bolt.Bucket, app, appID string, snapshot *domain.PruneProtectionSnapshot) {
261 var ownership domain.AppOwnership
262 found, err := getRecord(bucket, keyOwnership, &ownership, "ownership")
263 if err != nil {
264 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
265 Source: domain.InventorySourceOwnership,
266 Reason: domain.PruneReasonUnknownInventory,
267 Detail: fmt.Sprintf("app %s: ownership record unreadable", app),
268 })
269 return
270 }
271 if !found {
272 return
273 }
274 // The ownership record's own ID is authoritative: the app record's
275 // UUID can lag behind it after a re-deploy.
276 claimAppID := ownership.ID
277 if claimAppID == "" {
278 claimAppID = appID
279 }
280 claims, gap := claimsForOwnership(app, claimAppID, ownership)
281 if gap != nil {
282 snapshot.Gaps = append(snapshot.Gaps, *gap)
283 }
284 snapshot.VolumeClaims = append(snapshot.VolumeClaims, claims...)
285 snapshot.ImageClaims = append(snapshot.ImageClaims, imageClaimsForOwnership(app, claimAppID, ownership)...)
286}
287
288// readOwnershipHistoryLocked records every historical incarnation's
289// claims. A reused app name never overwrites them, so a volume retained
290// by a removed app stays protected after the name is taken again.
291func readOwnershipHistoryLocked(tx *bolt.Tx, snapshot *domain.PruneProtectionSnapshot) error {
292 history := tx.Bucket(bucketOwnershipHistory)
293 if history == nil {
294 return nil
295 }
296 return history.ForEach(func(incarnation, value []byte) error {
297 if value == nil {
298 return nil
299 }
300 var ownership domain.AppOwnership
301 if err := unmarshal(value, &ownership, "ownership history"); err != nil {
302 snapshot.Gaps = append(snapshot.Gaps, domain.InventoryGap{
303 Source: domain.InventorySourceOwnership,
304 Reason: domain.PruneReasonUnknownInventory,
305 Detail: fmt.Sprintf("ownership history %s unreadable", incarnation),
306 })
307 return nil
308 }
309 claimApp := ownership.App
310 if claimApp == "" {
311 claimApp = string(incarnation)
312 }
313 claims, gap := claimsForOwnership(claimApp, ownership.ID, ownership)
314 if gap != nil {
315 snapshot.Gaps = append(snapshot.Gaps, *gap)
316 return nil
317 }
318 snapshot.VolumeClaims = append(snapshot.VolumeClaims, claims...)
319 snapshot.ImageClaims = append(snapshot.ImageClaims, imageClaimsForOwnership(claimApp, ownership.ID, ownership)...)
320 return nil
321 })
322}
323
324// imageClaimsForOwnership converts one ownership record into image claims.
325// An absent or unrecognized lifecycle state maps conservatively to
326// retained: only an explicit released record can ever be eligible.
327func imageClaimsForOwnership(app, appID string, ownership domain.AppOwnership) []domain.ImageClaim {
328 claims := make([]domain.ImageClaim, 0, len(ownership.Images))
329 for _, image := range ownership.Images {
330 if image.Reference == "" && image.Digest == "" {
331 continue
332 }
333 claims = append(claims, domain.ImageClaim{
334 Reference: image.Reference,
335 Digest: image.Digest,
336 App: app,
337 AppID: appID,
338 Service: image.Service,
339 State: mapOwnedImageState(image.State),
340 })
341 }
342 return claims
343}
344
345// mapOwnedImageState maps a durable owned-image state to a claim state.
346// Unknown values protect.
347func mapOwnedImageState(state string) domain.VolumeClaimState {
348 switch state {
349 case domain.AppResourceReleased:
350 return domain.VolumeClaimReleased
351 case domain.AppResourceAttached:
352 return domain.VolumeClaimAttached
353 default:
354 return domain.VolumeClaimRetained
355 }
356}
357
358// claimsForOwnership converts one ownership record into volume claims.
359// An absent or unrecognized lifecycle state maps conservatively to
360// retained: only an explicit released record can ever be eligible.
361func claimsForOwnership(app, appID string, ownership domain.AppOwnership) ([]domain.VolumeClaim, *domain.InventoryGap) {
362 claims := make([]domain.VolumeClaim, 0, len(ownership.Volumes))
363 for _, volume := range ownership.Volumes {
364 name := volume.RuntimeName
365 if name == "" {
366 name = volume.Name
367 }
368 if name == "" {
369 continue
370 }
371 claims = append(claims, domain.VolumeClaim{
372 Name: name,
373 App: app,
374 AppID: appID,
375 Service: volume.Service,
376 State: mapOwnedVolumeState(volume.State),
377 })
378 }
379 return claims, nil
380}
381
382// mapOwnedVolumeState maps a durable owned-volume state to a claim
383// state. Unknown values protect.
384func mapOwnedVolumeState(state string) domain.VolumeClaimState {
385 switch state {
386 case domain.AppResourceReleased:
387 return domain.VolumeClaimReleased
388 case domain.AppResourceAttached:
389 return domain.VolumeClaimAttached
390 default:
391 return domain.VolumeClaimRetained
392 }
393}
394
395// dedupeRoots drops duplicate roots, preserving first-seen order.
396func dedupeRoots(roots []domain.ProtectionRoot) []domain.ProtectionRoot {
397 seen := make(map[string]struct{}, len(roots))
398 out := make([]domain.ProtectionRoot, 0, len(roots))
399 for _, root := range roots {
400 if !root.Valid() {
401 continue
402 }
403 key := string(root.Kind) + "\x00" + root.Ref + "\x00" + root.Repository
404 if _, dup := seen[key]; dup {
405 continue
406 }
407 seen[key] = struct{}{}
408 out = append(out, root)
409 }
410 return out
411}
412
413// dedupeClaims drops identical claims, preserving first-seen order. The
414// lifecycle state is part of the identity: a released claim and an
415// attached claim for the same runtime volume are different facts, and
416// collapsing them would lose the one that protects the volume.
417func dedupeClaims(claims []domain.VolumeClaim) []domain.VolumeClaim {
418 seen := make(map[string]struct{}, len(claims))
419 out := make([]domain.VolumeClaim, 0, len(claims))
420 for _, claim := range claims {
421 key := claim.Name + "\x00" + claim.AppID + "\x00" + claim.App + "\x00" + claim.Service + "\x00" + string(claim.State)
422 if _, dup := seen[key]; dup {
423 continue
424 }
425 seen[key] = struct{}{}
426 out = append(out, claim)
427 }
428 return out
429}