package main import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" ) // apiSeed is the record's seed as the CLI renders it // (provision.SeedSource's wire shape). type apiSeed struct { ID string `json:"id"` Kind string `json:"kind"` From string `json:"from,omitempty"` TXID string `json:"txid,omitempty"` SHA256 string `json:"sha256"` Bytes int64 `json:"bytes"` State string `json:"state"` Reason string `json:"reason,omitempty"` } // seedCell is the list's SEED column (seeded-instances spec §7). func seedCell(r apiInstanceRecord) string { s := r.Seed if s == nil { return "-" } switch s.State { case "reserving": return fmt.Sprintf("copying from %s", s.From) case "aborted", "rolled-back": return "aborting" case "refused": return fmt.Sprintf("refused: %s", s.Reason) } if s.Kind != "import" { return fmt.Sprintf("seeded from %s at txid %s", s.From, s.TXID) } switch s.State { case "awaiting-upload": return "import awaiting upload" case "uploaded", "ready": return "awaiting stop" case "swapped", "placed": return "swapped, awaiting verify" } sha := s.SHA256 if len(sha) > 12 { sha = sha[:12] } return fmt.Sprintf("imported %s", sha) } // Seams for the tests: how often a seeded create or an import follows the // record, and how it waits between looks. var ( seedPollEvery = 2 * time.Second seedWait = func(ctx context.Context, d time.Duration) error { t := time.NewTimer(d) defer t.Stop() select { case <-t.C: return nil case <-ctx.Done(): return ctx.Err() } } ) // errSeedWaitOver is pollSeed's limit running out. It wraps // context.DeadlineExceeded, and only pollSeed returns this value, so a // request's own timeout is never mistaken for it. var errSeedWaitOver = fmt.Errorf("%w", context.DeadlineExceeded) // seedGone is pollSeed's answer when the record it follows has gone. It // reads as the membership sentence, and an import tells it apart. type seedGone struct{ error } // seedPollFailures is how many looks in a row may fail before the create // gives up and returns the last error. const seedPollFailures = 5 // instanceCreateFrom is `carlos instances create --from` (seeded-instances // spec §4): the ordinary create body plus "from". A refusal before the // record is reserved comes back as an ordinary error. Once it is reserved // the console answers 202 with the reserving record and copies on its own; // that record is returned (accepted true) and the caller follows it with // followSeed. A console that answers 201 has finished the copy already. 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) { in := struct { Host string `json:"host"` Region string `json:"region,omitempty"` Channel string `json:"channel,omitempty"` Environment string `json:"environment,omitempty"` Message string `json:"message,omitempty"` From struct { Host string `json:"host"` TXID uint64 `json:"txid,omitempty"` } `json:"from"` }{Host: host, Region: region, Channel: channel, Environment: environment, Message: firstMutationMessage(messages)} in.From.Host, in.From.TXID = from, at status, body, err := s.postJSON(ctx, apiPath(a, app, "/instances"), in) if err != nil { return apiInstanceRecord{}, false, err } if status != http.StatusCreated && status != http.StatusAccepted { return apiInstanceRecord{}, false, apiErrorFromBody(status, body) } if err := json.Unmarshal(body, &rec); err != nil { return apiInstanceRecord{}, false, fmt.Errorf("instances create: unreadable response: %w", err) } return rec, status == http.StatusAccepted, nil } // seedFailure is the error a finished-badly seed becomes: the console's own // reason, or its generic sentence for the source. func seedFailure(s *apiSeed, source string) error { if s != nil && s.Reason != "" { return errors.New(s.Reason) } return fmt.Errorf("could not copy %s. Nothing was created. Try again.", source) } // followSeed polls the instance record until its seed leaves reserving, and // returns the record it last read. An aborted seed returns its reason as the // error. There is no overall timeout: the console's deadlines and its sweep // bring every seed to an end. func (s *apiSession) followSeed(ctx context.Context, a apiAcct, app string, first apiInstanceRecord) (apiInstanceRecord, error) { source, id := strings.TrimSpace(first.Host), "" if first.Seed != nil { source, id = first.Seed.From, first.Seed.ID } return s.pollSeed(ctx, a, app, first.Host, 0, func(rec apiInstanceRecord) (bool, error) { if rec.Seed == nil || rec.Seed.ID != id { // The record no longer carries this seed. return false, seedFailure(nil, source) } switch rec.Seed.State { case "reserving": return false, nil case "aborted", "rolled-back", "refused": return false, seedFailure(rec.Seed, source) } return true, nil }) } // pollSeed is the one poller both seeded paths use (--from's create and the // import): it reads host's record every seedPollEvery and hands it to look, // until look says done or returns an error, which pollSeed returns as it // is. A failed read is tried again on the next tick, up to seedPollFailures // in a row; a record that has gone returns at once. limit bounds the wait // (0 means none) and its end returns errSeedWaitOver. It counts both the // waits taken and the clock, so the seam alone can run it out. func (s *apiSession) pollSeed(ctx context.Context, a apiAcct, app, host string, limit time.Duration, look func(apiInstanceRecord) (bool, error)) (apiInstanceRecord, error) { start := time.Now() var waited time.Duration failures := 0 for { if limit > 0 && (waited >= limit || time.Since(start) >= limit) { return apiInstanceRecord{}, errSeedWaitOver } if err := seedWait(ctx, seedPollEvery); err != nil { return apiInstanceRecord{}, err } waited += seedPollEvery rec, found, err := s.instanceGet(ctx, a, app, host) switch { case err == nil && !found: return apiInstanceRecord{}, seedGone{notFoundErr()} case err != nil: if failures++; failures >= seedPollFailures { return apiInstanceRecord{}, err } continue } failures = 0 done, err := look(rec) if err != nil { return apiInstanceRecord{}, err } if done { return rec, nil } } } // runSeededCreate is the --from create end to end: the post, the // copying line, the follow, and the success output. It returns the console's // refusal or the seed's reason as the error, for the caller to print. func (s *apiSession) runSeededCreate(ctx context.Context, w io.Writer, a apiAcct, app, host, region, channel, environment, from string, at uint64, message string) error { rec, accepted, err := s.instanceCreateFrom(ctx, a, app, host, region, channel, environment, from, at, message) if err != nil { return err } if accepted { fmt.Fprintln(w, seedCopyingLine(from, host)) final, err := s.followSeed(ctx, a, app, rec) if err != nil { return err } // The look at one record does not carry where the create placed it. final.RegionName, final.RegionFrom = rec.RegionName, rec.RegionFrom rec = final } fmt.Fprintf(w, "%s %s\n", colorFor(w).ok("created instance"), instanceCreatedLine(rec, environmentSuffix(rec.Environment))) if rec.Seed != nil { mutedf(w, " %s", seedCell(rec)) } mutedf(w, " the host comes live on the next reconcile pass on its box") return nil }