retirement.go RawHistoryFolder

1package deployment
2
3import (
4	"context"
5	"errors"
6	"sort"
7	"time"
8
9	"github.com/bnema/zerowrap"
10
11	"github.com/bnema/gordon/internal/domain"
12)
13
14// stopGraceOrDefault returns the grace a runtime call must use for one
15// container. A zero declared grace means the app default: an app path
16// never hands the runtime an immediate kill.
17func stopGraceOrDefault(grace time.Duration) time.Duration {
18	if grace <= 0 {
19		return domain.AppDefaultStopGrace
20	}
21	return grace
22}
23
24// serviceStopGrace returns the effective stop grace of one service.
25func serviceStopGrace(eff domain.AppEffectiveService) time.Duration {
26	return stopGraceOrDefault(eff.Spec.StopGrace)
27}
28
29// activeStopGrace returns the effective stop grace recorded for one
30// service of an ACTIVE record, or the app default when the record has no
31// such service. Retiring a replaced container uses the grace of the
32// generation being stopped, never the grace of its replacement.
33func activeStopGrace(active domain.AppActive, service string) time.Duration {
34	return stopGraceOrDefault(active.Services[service].Spec.StopGrace)
35}
36
37// retireOptions control one exact-container retirement.
38type retireOptions struct {
39	// Service names the app service for warnings.
40	Service string
41	// Grace is the effective stop grace of the container being retired.
42	Grace time.Duration
43	// Force removes a container that was never published and holds no
44	// committed state, without waiting for a grace period.
45	Force bool
46	// ClearInhibition drops the generation's recovery inhibition once
47	// the container is confirmed gone. It is only safe when nothing can
48	// recreate this generation: an operator stop or a removal. A
49	// replacement keeps its inhibition until the new generation is
50	// published, because recovery must not recreate the replaced writer
51	// from the still-active old revision.
52	ClearInhibition bool
53}
54
55// containerRetirement is the outcome of one exact-container retirement.
56type containerRetirement struct {
57	// ContainerID is the container the retirement targeted.
58	ContainerID string
59	// Gone is true only when the runtime confirmed the container no
60	// longer exists. Not-found counts as confirmation.
61	Gone bool
62	// Warnings are bounded, log-free leftovers for operator action.
63	Warnings []CleanupWarning
64}
65
66// retireContainer is the single retirement path for one exact container:
67// stop it with the effective grace (or force-remove a never-published
68// candidate), confirm the exact ID is gone, and only then release its
69// backend claims and clear its recovery inhibition. A container that is
70// not confirmed gone keeps its claims and its inhibition, so no later
71// generation can dial a recycled loopback port and no recovery pass can
72// revive a half-removed generation.
73func (s *Service) retireContainer(ctx context.Context, app string, opts retireOptions, containerID string) containerRetirement {
74	result := containerRetirement{ContainerID: containerID}
75	if containerID == "" {
76		result.Gone = true
77		return result
78	}
79	if err := s.stopForRetirement(ctx, opts, containerID); err != nil {
80		result.Warnings = append(result.Warnings, CleanupWarning{
81			Service: opts.Service, Leftover: containerID, Detail: "stop: " + err.Error(),
82		})
83		return result
84	}
85	if err := s.deps.Runtime.RemoveContainer(ctx, containerID, opts.Force); err != nil && !errors.Is(err, domain.ErrContainerNotFound) {
86		result.Warnings = append(result.Warnings, CleanupWarning{
87			Service: opts.Service, Leftover: containerID, Detail: "remove: " + err.Error(),
88		})
89		return result
90	}
91	result.Gone = true
92	if err := s.deps.State.ReleaseBackendBinds(ctx, app, containerID); err != nil {
93		result.Warnings = append(result.Warnings, CleanupWarning{
94			Service: opts.Service, Leftover: containerID, Detail: "release backend claims: " + err.Error(),
95		})
96	}
97	// The app lock is held, so no candidate of this app can be between
98	// secret file write and container create.
99	if err := s.sweepSecretFiles(ctx, app); err != nil {
100		// Fixed detail: the error may name host paths, which must stay
101		// out of journals and API responses.
102		log := zerowrap.FromCtx(ctx)
103		log.Warn().Err(err).Str("app", app).Str("service", opts.Service).Msg("deployment: secret files cleanup failed")
104		result.Warnings = append(result.Warnings, CleanupWarning{
105			Service: opts.Service, Leftover: containerID, Detail: "secret files cleanup failed",
106		})
107	}
108	if opts.ClearInhibition {
109		// Confirmed gone: the marker has nothing left to protect, and a
110		// stale record would only confuse the next recreation.
111		if err := s.clearRecoveryInhibition(ctx, app, opts.Service, containerID); err != nil {
112			result.Warnings = append(result.Warnings, CleanupWarning{
113				Service: opts.Service, Leftover: containerID, Detail: "clear recovery inhibition: " + err.Error(),
114			})
115		}
116	}
117	return result
118}
119
120// stopForRetirement stops one container. A forced retirement removes
121// without a grace period: only a candidate that was never published and
122// holds no committed state may be retired that way.
123func (s *Service) stopForRetirement(ctx context.Context, opts retireOptions, containerID string) error {
124	if opts.Force {
125		return nil
126	}
127	if err := s.deps.Runtime.StopContainer(ctx, containerID, stopGraceOrDefault(opts.Grace)); err != nil && !errors.Is(err, domain.ErrContainerNotFound) {
128		return err
129	}
130	return nil
131}
132
133// retireCandidate removes a candidate that was never published and
134// releases its backend claims. The candidate holds no committed state, so
135// it is force-removed without a grace period. Cleanup failures are
136// returned as bounded warnings: a candidate that could not be removed
137// must not hide the failure the caller is already reporting.
138func (s *Service) retireCandidate(ctx context.Context, app, service, containerID string) []CleanupWarning {
139	retired := s.retireContainer(ctx, app, retireOptions{Service: service, Force: true}, containerID)
140	return retired.Warnings
141}
142
143// cleanupDetail renders the first bounded warning of a failed retirement
144// for an error message. Journal and log content never travel here.
145func cleanupDetail(retired containerRetirement) string {
146	if len(retired.Warnings) == 0 {
147		return "container not confirmed gone"
148	}
149	return retired.Warnings[0].Detail
150}
151
152// collectCleanupWarnings gathers one operation's per-service leftovers in
153// stable service order. It is the single source of the operation-level
154// warning list.
155func collectCleanupWarnings(results map[string]ServiceResult) []CleanupWarning {
156	names := make([]string, 0, len(results))
157	for name := range results {
158		names = append(names, name)
159	}
160	sort.Strings(names)
161	var warnings []CleanupWarning
162	for _, name := range names {
163		warnings = append(warnings, results[name].CleanupWarnings...)
164	}
165	return warnings
166}
167
168// journalWarnings maps leftovers into the bounded journal shape. The
169// service name travels with each warning, so a leftover is always
170// attributable without parsing its text.
171func journalWarnings(leftovers []CleanupWarning) []domain.AppOperationWarning {
172	var warnings []domain.AppOperationWarning
173	for _, warning := range leftovers {
174		warnings = append(warnings, domain.AppOperationWarning{
175			Service:  warning.Service,
176			Leftover: warning.Leftover,
177			Detail:   warning.Detail,
178		})
179	}
180	return warnings
181}

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