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