package moves import ( "context" "encoding/json" "errors" "fmt" "os" "sort" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // The move log (spec §13, Plan 6 Task 3): one object per transition under the // move's report prefix, log/.json, in the deployment bucket beside // the reports and the stop evidence. // // The key is the transition's identity, never a counter (round-2 finding // 23): - for a phase step (cutting-stopped, landing-probing, // rolling_back-source-claimed), probe_attempt- for the landing's n-th // probe, stuck- and retry- keyed by the record's recovery retry // epoch (claim Plan 10, preflight L11), and the terminal outcome itself // (done, rolled_back, aborted). An object is written create-only: the first // writer of a transition wins, and a second writer, or a replay, meets the // existing object, which is success with nothing created (preflight L27). // The log is read in At order (ReadLog), never by key. // LogEvent is one move log object. type LogEvent struct { V int `json:"v"` Op string `json:"op"` Host string `json:"host"` Event string `json:"event"` Identity string `json:"identity"` Actor string `json:"actor"` At time.Time `json:"at"` Fields map[string]string `json:"fields,omitempty"` } // ErrLogIdentity refuses an identity that is not one key segment. var ErrLogIdentity = errors.New("moves: a log identity is one key segment") func validLogIdentity(id string) bool { return id != "" && id != "." && id != ".." && !strings.ContainsAny(id, "/\\\x00") } // WriteLog writes ev at LogKeyFor(prefix, ev.Identity), create-only. created // is true only for the writer that made the object; an existing object is // success with created false. func WriteLog(ctx context.Context, s release.Store, prefix string, ev LogEvent) (created bool, err error) { if !validLogIdentity(ev.Identity) { return false, fmt.Errorf("%w: %q", ErrLogIdentity, ev.Identity) } body, err := json.Marshal(ev) if err != nil { return false, err } if _, err := s.Put(ctx, LogKeyFor(prefix, ev.Identity), body, ""); err != nil { if errors.Is(err, release.ErrCASConflict) { return false, nil } return false, err } return true, nil } // ReadLog reads every object under prefix's log/, ordered by At (then by // identity, for two at the same instant). An object that went missing // between the list and the read is skipped. func ReadLog(ctx context.Context, s release.Store, prefix string) ([]LogEvent, error) { keys, err := s.List(ctx, prefix+"log/") if err != nil { return nil, err } out := make([]LogEvent, 0, len(keys)) for _, k := range keys { body, _, err := s.Get(ctx, k) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { return nil, err } var ev LogEvent if err := json.Unmarshal(body, &ev); err != nil { return nil, fmt.Errorf("moves: log object %s: %w", k, err) } out = append(out, ev) } sort.SliceStable(out, func(i, j int) bool { if !out[i].At.Equal(out[j].At) { return out[i].At.Before(out[j].At) } return out[i].Identity < out[j].Identity }) return out, nil } // Timings (spec §13, Plan 6 Task 4): how long a move took, phase by phase, // each beside its budget, in seconds. They are worked out once, from the // record's own journal times, when the done or rolled_back entry is built // (TransitionOf, whoever writes it), never from a clock at delivery, and // carried in that entry's Fields; detail=1 shows them from there (TimingsOf). // // - prepare_s: requested_at to ready_at. // - cut_s: cut_at to flipped_at. // - land_s: flipped_at to opened_at, against land_budget_s, the landing // deadline (provision.LandingDeadlineOf: the earlier of cut_deadline and // landing_deadline) less flipped_at. // - offline_s: cut_at to the open, or to the source's reopen after a // rollback or a revert (provision.MoveOfflineS, the closes' own), // against cut_budget_s, the admitted stop-to-open estimate (T11). // - rollback_s: a rollback's decision (rollback.requested_at) to the // source's reopen (rollback.opened_at), against release_budget_s, its // release deadline less its decision. The record stamps no "released", // so the rollback is measured to the reopen, which comes only after the // target's release. // // A timing whose ends are not both stamped is left out, and so is a budget // the record does not carry. const ( TimingPrepare = "prepare_s" TimingCut = "cut_s" TimingCutBudget = "cut_budget_s" TimingLand = "land_s" TimingLandBudget = "land_budget_s" TimingOffline = "offline_s" TimingRollback = "rollback_s" TimingReleaseBudget = "release_budget_s" ) // timingKeys are every key Timings may set. var timingKeys = []string{TimingPrepare, TimingCut, TimingCutBudget, TimingLand, TimingLandBudget, TimingOffline, TimingRollback, TimingReleaseBudget} // Timings is m's timings (see above); empty for no move. func Timings(m *provision.MoveState) map[string]float64 { out := map[string]float64{} if m == nil { return out } span := func(key string, from, to *time.Time) { if from != nil && to != nil { out[key] = to.Sub(*from).Seconds() } } if !m.RequestedAt.IsZero() { span(TimingPrepare, &m.RequestedAt, m.ReadyAt) } span(TimingCut, m.CutAt, m.FlippedAt) if m.CutBudgetS > 0 { out[TimingCutBudget] = float64(m.CutBudgetS) } if m.State == provision.MoveCommitted { span(TimingLand, m.FlippedAt, m.OpenedAt) } if d, _ := provision.LandingDeadlineOf(m); m.FlippedAt != nil { span(TimingLandBudget, m.FlippedAt, d) } if s, ok := provision.MoveOfflineS(m); ok { out[TimingOffline] = s } if rb := m.Rollback; rb != nil { span(TimingRollback, rb.RequestedAt, rb.OpenedAt) span(TimingReleaseBudget, rb.RequestedAt, rb.ReleaseDeadline) } return out } // TimingsOf is the timings a logged entry's Fields carry: the timing keys // alone, each parsed back to seconds; anything else in the fields, and a // value that does not parse, is left out. func TimingsOf(fields map[string]string) map[string]float64 { out := map[string]float64{} for _, k := range timingKeys { v, ok := fields[k] if !ok { continue } if f, err := strconv.ParseFloat(v, 64); err == nil { out[k] = f } } return out } // timingText is a timing as a field's text: the shortest decimal that // parses back to it. func timingText(s float64) string { return strconv.FormatFloat(s, 'f', -1, 64) }