carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package hostactors
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "time"
8
9 "github.com/carlosframework/platform/internal/release"
10)
11
12// Group membership, confirmed by the console (region HA spec 2 §5 step 5,
13// H16, H28). Before it invites a box its region's group launched, and
14// again before it enrolls it, the console asks the cloud, read-only and
15// with its own credentials, whether the instance is a current member of
16// the region's group. No box writes or contributes membership evidence:
17// a confirmation exists only inside this package, built by
18// confirmMembership from one MembershipReader answer, and nothing
19// exported takes one in.
20//
21// What the cloud says is the reader's business; the AWS adapter is
22// internal/hostactors/awsmembership. This file names no provider (§25).
23
24// MembershipReader answers, read-only, from the cloud the roster's region
25// entries name. It is the console's own client.
26type MembershipReader interface {
27 // Member describes instance in awsRegion: its group, if any, and its
28 // state. An instance in no group is a Membership with no GroupName. An
29 // error is a read that failed, never an answer; the Membership beside
30 // it then carries only the RequestIDs of the calls made, so the log
31 // keeps whatever ids the cloud gave.
32 Member(ctx context.Context, awsRegion, instance string) (Membership, error)
33}
34
35// Membership is one instance's group membership, as the cloud reported it.
36type Membership struct {
37 GroupName string
38 AvailabilityZoneRegion string
39 LifecycleState string
40 InstanceState string
41 Owner string
42 // RequestIDs are the cloud's request ids for the reads, one per call
43 // made, in order: the id the cloud gave, in its answer or in its error
44 // response, or NoRequestID for a call that failed before the cloud
45 // answered.
46 RequestIDs []string
47}
48
49// NoRequestID stands in a Membership's RequestIDs for a call that failed
50// before the cloud answered, so it gave no id. It is never an id: a read
51// that carries it is not an answer.
52const NoRequestID = "none"
53
54// MembershipEvidence is what a confirmation read, for the console's log:
55// the cloud's request ids, and when the read began.
56type MembershipEvidence struct {
57 RequestIDs []string
58 ObservedAt time.Time
59}
60
61// membershipFreshness is the write deadline: a read's write is issued
62// less than membershipFreshness after the read's anchor, never at or after
63// it.
64const membershipFreshness = time.Minute
65
66var (
67 // ErrMembershipUnknown is a membership read that failed. It says
68 // nothing about the instance: the console tries again with the same
69 // attempt.
70 ErrMembershipUnknown = errors.New("hostactors: group membership could not be read")
71 // ErrNotMember is the cloud's positive answer that the instance is not
72 // a current, running member of the region's group in the deployment's
73 // account and AWS region.
74 ErrNotMember = errors.New("hostactors: the instance is not a current member of the region's group")
75 // ErrMembershipStale is a read that was already membershipFreshness old
76 // or more when the describe returned: the cloud was slow, and the
77 // answer counts against the attempt.
78 ErrMembershipStale = errors.New("hostactors: the membership read is more than a minute old")
79 // ErrWriteDeadline is a write the deadline stopped (writeBound): no
80 // store call was issued at or after it, but one already in flight was
81 // cancelled, so whether it committed is unknown. The next pass reads
82 // back and describes again.
83 ErrWriteDeadline = errors.New("hostactors: the write would be issued more than a minute after its membership read")
84)
85
86// membershipConfirmation is one read of an attempt's instance. It is built
87// only by confirmMembership, from the reader's answer, and never leaves
88// this package. A read the cloud answered carries its request ids and
89// time whether or not it found a member, so every read can be logged.
90type membershipConfirmation struct {
91 requestIDs []string
92 observedAt time.Time
93}
94
95// confirmMembership asks r about instance in region's AWS region, and
96// returns the confirmation only when the answer is a current member of
97// region's group: Pending or InService, pending or running, owned by the
98// region's account, in its AWS region, with a request id for each call.
99// The read's anchor, its time, is taken before the call: never later than
100// the describe's completion, so its age is never understated.
101//
102// The call gets at most storeCallTimeout, as each bound store call does:
103// neither describe sets a deadline of its own, and a connection that
104// stalls would otherwise hold the serial pass, and every box behind this
105// one, for as long as it stalls. A call cut off, by the timeout or by the
106// pass's own context, is unknown whatever it returned: the attempt stands
107// and its wait does not grow.
108func confirmMembership(ctx context.Context, r MembershipReader, region Region, instance string, now func() time.Time) (membershipConfirmation, error) {
109 if r == nil || now == nil {
110 return membershipConfirmation{}, fmt.Errorf("%w: no reader", ErrMembershipUnknown)
111 }
112 at := now().UTC()
113 rctx, cancel := context.WithTimeout(ctx, storeCallTimeout)
114 m, err := r.Member(rctx, region.AWSRegion, instance)
115 cut := rctx.Err()
116 cancel()
117 // The ids the cloud gave are kept whatever came of the read, failed
118 // calls included, for the console's log.
119 c := membershipConfirmation{requestIDs: append([]string(nil), m.RequestIDs...), observedAt: at}
120 if cut != nil {
121 return c, fmt.Errorf("%w: the read did not finish in time: %v", ErrMembershipUnknown, cut)
122 }
123 if err != nil {
124 return c, fmt.Errorf("%w: %v", ErrMembershipUnknown, err)
125 }
126 // A reply without an id from each call is not an answer, whatever it
127 // claims, so it is never counted against the attempt.
128 if len(m.RequestIDs) < 2 {
129 return c, fmt.Errorf("%w: the cloud gave no request ids", ErrMembershipUnknown)
130 }
131 for _, id := range m.RequestIDs {
132 if id == "" || id == NoRequestID {
133 return c, fmt.Errorf("%w: the cloud gave a blank request id", ErrMembershipUnknown)
134 }
135 }
136 switch {
137 case m.GroupName != region.GroupName:
138 return c, fmt.Errorf("%w: it is in group %q", ErrNotMember, m.GroupName)
139 case m.LifecycleState != "Pending" && m.LifecycleState != "InService":
140 return c, fmt.Errorf("%w: its lifecycle state is %q", ErrNotMember, m.LifecycleState)
141 case m.InstanceState == "" || m.Owner == "":
142 // Auto Scaling knows it but EC2 does not list it (yet, or any
143 // more): no answer.
144 return c, fmt.Errorf("%w: EC2 does not list it", ErrMembershipUnknown)
145 case m.InstanceState != "pending" && m.InstanceState != "running":
146 return c, fmt.Errorf("%w: it is %q", ErrNotMember, m.InstanceState)
147 case m.Owner != region.Account || m.AvailabilityZoneRegion != region.AWSRegion:
148 return c, fmt.Errorf("%w: it is in account %q, region %q", ErrNotMember, m.Owner, m.AvailabilityZoneRegion)
149 }
150 return c, nil
151}
152
153// fresh refuses a confirmation membershipFreshness old or more at now, or
154// one from the future: the describe itself was too slow.
155func (c membershipConfirmation) fresh(now time.Time) error {
156 if c.observedAt.IsZero() || now.Sub(c.observedAt) >= membershipFreshness || c.observedAt.After(now.Add(5*time.Second)) {
157 return fmt.Errorf("%w (read at %s, checked at %s)", ErrMembershipStale, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339))
158 }
159 return nil
160}
161
162// deadline is the last moment a write this read allows may be issued.
163func (c membershipConfirmation) deadline() time.Time { return c.observedAt.Add(membershipFreshness) }
164
165// atWrite is the bound store's check before each call: at or after the
166// deadline it is ErrWriteDeadline, unknown, never a refusal.
167func (c membershipConfirmation) atWrite(now time.Time) error {
168 if c.observedAt.IsZero() || !now.Before(c.deadline()) {
169 return fmt.Errorf("%w (read at %s, write at %s)", ErrWriteDeadline, c.observedAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339))
170 }
171 return nil
172}
173
174func (c membershipConfirmation) evidence() MembershipEvidence {
175 return MembershipEvidence{RequestIDs: append([]string(nil), c.requestIDs...), ObservedAt: c.observedAt}
176}
177
178// writeBound is d with every store call bound to c's deadline (C2, S1),
179// the single enforcement point of the write's freshness. Each store call
180// (a read, the write, the read-back after a lost write) is refused before
181// it is issued once the deadline is reached, by either clock, the
182// context's or now; the returned context ends at the deadline; and each
183// call it does issue gets at most storeCallTimeout (U2). The sealed
184// writers (Invite, Replace and the checkMembership it makes) run on it
185// unchanged, so no read, signature's Put or read-back of theirs is issued
186// 60 seconds or more after the membership read's anchor, and any outcome
187// not confirmed in time is ErrWriteDeadline, unknown.
188func writeBound(ctx context.Context, d Directory, c membershipConfirmation, now func() time.Time) (Directory, context.Context, context.CancelFunc) {
189 bctx, cancel := context.WithTimeout(ctx, c.deadline().Sub(now()))
190 bd := d
191 bd.Store = deadlineStore{inner: d.Store, check: func(callCtx context.Context) error {
192 if bctx.Err() != nil || callCtx.Err() != nil {
193 return fmt.Errorf("%w: the deadline passed", ErrWriteDeadline)
194 }
195 return c.atWrite(now())
196 }}
197 return bd, bctx, cancel
198}
199
200// storeCallTimeout is the hard limit on one store call of a bound write
201// (U2): a call the store has not answered within it is unknown, and its
202// context is cancelled, so an S3 request is abandoned at the HTTP layer.
203// It bounds how long the console waits, not when a write commits: a
204// call cut off may still commit later, and is settled from the roster on
205// a later pass. confirmMembership gives each membership read the same
206// limit. A var so tests can shorten it.
207var storeCallTimeout = 15 * time.Second
208
209// deadlineStore refuses every call once check says the deadline has been
210// reached, before the call reaches the store underneath, and gives each
211// call it issues at most storeCallTimeout. The bound cannot rest on the
212// store alone: release.DirStore ignores its context, so the wrapper
213// returns on its own when the call outlives the timeout or the deadline,
214// whatever the store does. Every outcome it cannot confirm in time is
215// ErrWriteDeadline, unknown: a call cut off in flight, one that never
216// answered, and one that answered, even successfully, only after the
217// deadline.
218//
219// The store underneath is a named field, not embedded, and every method of
220// release.Store is written out here: a method added to the interface later
221// fails to compile instead of reaching the store past the bound.
222type deadlineStore struct {
223 inner release.Store
224 check func(context.Context) error
225}
226
227var _ release.Store = deadlineStore{}
228
229// call runs one store call under the bound.
230func (s deadlineStore) call(ctx context.Context, f func(context.Context) error) error {
231 if err := s.check(ctx); err != nil {
232 return err
233 }
234 cctx, cancel := context.WithTimeout(ctx, storeCallTimeout)
235 defer cancel()
236 done := make(chan error, 1)
237 go func() { done <- f(cctx) }()
238 var err error
239 select {
240 case err = <-done:
241 case <-cctx.Done():
242 }
243 // Cut off, whether the store gave up on the context or not: by the
244 // deadline, or by the hard timeout. Whatever it answered, if anything,
245 // is not known to be the outcome.
246 if cctx.Err() != nil {
247 if ctx.Err() != nil {
248 return fmt.Errorf("%w: in flight at the deadline", ErrWriteDeadline)
249 }
250 return fmt.Errorf("%w: the store did not answer in time", ErrWriteDeadline)
251 }
252 if err == nil {
253 if late := s.check(ctx); late != nil {
254 return fmt.Errorf("%w: it answered only after the deadline", ErrWriteDeadline)
255 }
256 }
257 return err
258}
259
260func (s deadlineStore) Get(ctx context.Context, key string) ([]byte, string, error) {
261 var body []byte
262 var etag string
263 err := s.call(ctx, func(cctx context.Context) error {
264 var err error
265 body, etag, err = s.inner.Get(cctx, key)
266 return err
267 })
268 if err != nil {
269 return nil, "", err
270 }
271 return body, etag, nil
272}
273
274func (s deadlineStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
275 var etag string
276 err := s.call(ctx, func(cctx context.Context) error {
277 var err error
278 etag, err = s.inner.Put(cctx, key, body, ifMatch)
279 return err
280 })
281 if err != nil {
282 return "", err
283 }
284 return etag, nil
285}
286
287func (s deadlineStore) List(ctx context.Context, prefix string) ([]string, error) {
288 var keys []string
289 err := s.call(ctx, func(cctx context.Context) error {
290 var err error
291 keys, err = s.inner.List(cctx, prefix)
292 return err
293 })
294 if err != nil {
295 return nil, err
296 }
297 return keys, nil
298}
299
300func (s deadlineStore) ListDirs(ctx context.Context, prefix string) ([]string, error) {
301 var dirs []string
302 err := s.call(ctx, func(cctx context.Context) error {
303 var err error
304 dirs, err = s.inner.ListDirs(cctx, prefix)
305 return err
306 })
307 if err != nil {
308 return nil, err
309 }
310 return dirs, nil
311}
312
313func (s deadlineStore) Delete(ctx context.Context, key string) error {
314 return s.call(ctx, func(cctx context.Context) error { return s.inner.Delete(cctx, key) })
315}
316