v3.3.1 31f1eec

protection.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close