carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "strings"
11 "time"
12)
13
14// apiSeed is the record's seed as the CLI renders it
15// (provision.SeedSource's wire shape).
16type apiSeed struct {
17 ID string `json:"id"`
18 Kind string `json:"kind"`
19 From string `json:"from,omitempty"`
20 TXID string `json:"txid,omitempty"`
21 SHA256 string `json:"sha256"`
22 Bytes int64 `json:"bytes"`
23 State string `json:"state"`
24 Reason string `json:"reason,omitempty"`
25}
26
27// seedCell is the list's SEED column (seeded-instances spec §7).
28func seedCell(r apiInstanceRecord) string {
29 s := r.Seed
30 if s == nil {
31 return "-"
32 }
33 switch s.State {
34 case "reserving":
35 return fmt.Sprintf("copying from %s", s.From)
36 case "aborted", "rolled-back":
37 return "aborting"
38 case "refused":
39 return fmt.Sprintf("refused: %s", s.Reason)
40 }
41 if s.Kind != "import" {
42 return fmt.Sprintf("seeded from %s at txid %s", s.From, s.TXID)
43 }
44 switch s.State {
45 case "awaiting-upload":
46 return "import awaiting upload"
47 case "uploaded", "ready":
48 return "awaiting stop"
49 case "swapped", "placed":
50 return "swapped, awaiting verify"
51 }
52 sha := s.SHA256
53 if len(sha) > 12 {
54 sha = sha[:12]
55 }
56 return fmt.Sprintf("imported %s", sha)
57}
58
59// Seams for the tests: how often a seeded create or an import follows the
60// record, and how it waits between looks.
61var (
62 seedPollEvery = 2 * time.Second
63 seedWait = func(ctx context.Context, d time.Duration) error {
64 t := time.NewTimer(d)
65 defer t.Stop()
66 select {
67 case <-t.C:
68 return nil
69 case <-ctx.Done():
70 return ctx.Err()
71 }
72 }
73)
74
75// errSeedWaitOver is pollSeed's limit running out. It wraps
76// context.DeadlineExceeded, and only pollSeed returns this value, so a
77// request's own timeout is never mistaken for it.
78var errSeedWaitOver = fmt.Errorf("%w", context.DeadlineExceeded)
79
80// seedGone is pollSeed's answer when the record it follows has gone. It
81// reads as the membership sentence, and an import tells it apart.
82type seedGone struct{ error }
83
84// seedPollFailures is how many looks in a row may fail before the create
85// gives up and returns the last error.
86const seedPollFailures = 5
87
88// instanceCreateFrom is `carlos instances create --from` (seeded-instances
89// spec §4): the ordinary create body plus "from". A refusal before the
90// record is reserved comes back as an ordinary error. Once it is reserved
91// the console answers 202 with the reserving record and copies on its own;
92// that record is returned (accepted true) and the caller follows it with
93// followSeed. A console that answers 201 has finished the copy already.
94func (s *apiSession) instanceCreateFrom(ctx context.Context, a apiAcct, app, host, region, channel, environment, from string, at uint64, messages ...string) (rec apiInstanceRecord, accepted bool, err error) {
95 in := struct {
96 Host string `json:"host"`
97 Region string `json:"region,omitempty"`
98 Channel string `json:"channel,omitempty"`
99 Environment string `json:"environment,omitempty"`
100 Message string `json:"message,omitempty"`
101 From struct {
102 Host string `json:"host"`
103 TXID uint64 `json:"txid,omitempty"`
104 } `json:"from"`
105 }{Host: host, Region: region, Channel: channel, Environment: environment, Message: firstMutationMessage(messages)}
106 in.From.Host, in.From.TXID = from, at
107 status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in)
108 if err != nil {
109 return apiInstanceRecord{}, false, err
110 }
111 if status != http.StatusCreated && status != http.StatusAccepted {
112 return apiInstanceRecord{}, false, apiErrorFromBody(status, body)
113 }
114 if err := json.Unmarshal(body, &rec); err != nil {
115 return apiInstanceRecord{}, false, fmt.Errorf("instances create: unreadable response: %w", err)
116 }
117 return rec, status == http.StatusAccepted, nil
118}
119
120// seedFailure is the error a finished-badly seed becomes: the console's own
121// reason, or its generic sentence for the source.
122func seedFailure(s *apiSeed, source string) error {
123 if s != nil && s.Reason != "" {
124 return errors.New(s.Reason)
125 }
126 return fmt.Errorf("could not copy %s. Nothing was created. Try again.", source)
127}
128
129// followSeed polls the instance record until its seed leaves reserving, and
130// returns the record it last read. An aborted seed returns its reason as the
131// error. There is no overall timeout: the console's deadlines and its sweep
132// bring every seed to an end.
133func (s *apiSession) followSeed(ctx context.Context, a apiAcct, app string, first apiInstanceRecord) (apiInstanceRecord, error) {
134 source, id := strings.TrimSpace(first.Host), ""
135 if first.Seed != nil {
136 source, id = first.Seed.From, first.Seed.ID
137 }
138 return s.pollSeed(ctx, a, app, first.Host, 0, func(rec apiInstanceRecord) (bool, error) {
139 if rec.Seed == nil || rec.Seed.ID != id {
140 // The record no longer carries this seed.
141 return false, seedFailure(nil, source)
142 }
143 switch rec.Seed.State {
144 case "reserving":
145 return false, nil
146 case "aborted", "rolled-back", "refused":
147 return false, seedFailure(rec.Seed, source)
148 }
149 return true, nil
150 })
151}
152
153// pollSeed is the one poller both seeded paths use (--from's create and the
154// import): it reads host's record every seedPollEvery and hands it to look,
155// until look says done or returns an error, which pollSeed returns as it
156// is. A failed read is tried again on the next tick, up to seedPollFailures
157// in a row; a record that has gone returns at once. limit bounds the wait
158// (0 means none) and its end returns errSeedWaitOver. It counts both the
159// waits taken and the clock, so the seam alone can run it out.
160func (s *apiSession) pollSeed(ctx context.Context, a apiAcct, app, host string, limit time.Duration, look func(apiInstanceRecord) (bool, error)) (apiInstanceRecord, error) {
161 start := time.Now()
162 var waited time.Duration
163 failures := 0
164 for {
165 if limit > 0 && (waited >= limit || time.Since(start) >= limit) {
166 return apiInstanceRecord{}, errSeedWaitOver
167 }
168 if err := seedWait(ctx, seedPollEvery); err != nil {
169 return apiInstanceRecord{}, err
170 }
171 waited += seedPollEvery
172 rec, found, err := s.instanceGet(ctx, a, app, host)
173 switch {
174 case err == nil && !found:
175 return apiInstanceRecord{}, seedGone{notFoundErr()}
176 case err != nil:
177 if failures++; failures >= seedPollFailures {
178 return apiInstanceRecord{}, err
179 }
180 continue
181 }
182 failures = 0
183 done, err := look(rec)
184 if err != nil {
185 return apiInstanceRecord{}, err
186 }
187 if done {
188 return rec, nil
189 }
190 }
191}
192
193// runSeededCreate is the --from create end to end: the post, the
194// copying line, the follow, and the success output. It returns the console's
195// refusal or the seed's reason as the error, for the caller to print.
196func (s *apiSession) runSeededCreate(ctx context.Context, w io.Writer, a apiAcct, app, host, region, channel, environment, from string, at uint64, message string) error {
197 rec, accepted, err := s.instanceCreateFrom(ctx, a, app, host, region, channel, environment, from, at, message)
198 if err != nil {
199 return err
200 }
201 if accepted {
202 fmt.Fprintln(w, seedCopyingLine(from, host))
203 final, err := s.followSeed(ctx, a, app, rec)
204 if err != nil {
205 return err
206 }
207 // The look at one record does not carry where the create placed it.
208 final.RegionName, final.RegionFrom = rec.RegionName, rec.RegionFrom
209 rec = final
210 }
211 fmt.Fprintf(w, "%s %s\n", colorFor(w).ok("created instance"), instanceCreatedLine(rec, environmentSuffix(rec.Environment)))
212 if rec.Seed != nil {
213 mutedf(w, " %s", seedCell(rec))
214 }
215 mutedf(w, " the host comes live on the next reconcile pass on its box")
216 return nil
217}
218