| 1 | package main |
| 2 | |
| 3 | import ( |
| 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). |
| 16 | type 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). |
| 28 | func 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. |
| 61 | var ( |
| 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. |
| 78 | var 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. |
| 82 | type 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. |
| 86 | const 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. |
| 94 | func (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. |
| 122 | func 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. |
| 133 | func (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. |
| 160 | func (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. |
| 196 | func (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 | |