| 1 | package hostops |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "errors" |
| 7 | |
| 8 | "github.com/carlosframework/platform/internal/controlname" |
| 9 | "github.com/carlosframework/platform/internal/hostclaim" |
| 10 | "github.com/carlosframework/platform/internal/moves" |
| 11 | "github.com/carlosframework/platform/internal/provision" |
| 12 | "github.com/carlosframework/platform/internal/release" |
| 13 | ) |
| 14 | |
| 15 | // The recovery pass's move log writer (Plan 6 Task 3, fix round 3, ruling 1). |
| 16 | // The console queues the move log entries its move pass makes in its outbox |
| 17 | // (move_log_outbox) and delivers them through this writer, once per recovery |
| 18 | // pass (Recovery.MoveLogs), whatever the moves have done since: a grant is |
| 19 | // spent by the time its transition's entry exists, and the claim's release |
| 20 | // never waits for the log. The writer exposes no store: one create-only entry |
| 21 | // per call, under the entry's own move's report prefix. |
| 22 | |
| 23 | // MoveLogEntryMax bounds one entry the writer writes, in bytes. |
| 24 | const MoveLogEntryMax = 4096 |
| 25 | |
| 26 | // ErrMoveLogTaken is an entry whose name already holds other bytes: the same |
| 27 | // transition, logged first by another writer. Nothing is written. |
| 28 | var ErrMoveLogTaken = errors.New("hostops: the move log entry was written by another writer") |
| 29 | |
| 30 | // MoveLogEntry is one move log entry to deliver: the move's host and id, the |
| 31 | // instance incarnation the move's evidence carried, the entry's name (its |
| 32 | // identity) and its exact bytes. It names no account or app: those are the |
| 33 | // host's claim's, once its holder is that incarnation. |
| 34 | type MoveLogEntry struct { |
| 35 | Host, Op, Incarnation, Name string |
| 36 | Body []byte |
| 37 | } |
| 38 | |
| 39 | // MoveLogWriter is one recovery pass's move log writer, valid only while its |
| 40 | // hook runs. |
| 41 | type MoveLogWriter struct { |
| 42 | w *ControlWriter |
| 43 | state *moveRecoveryGrantState |
| 44 | } |
| 45 | |
| 46 | // runMoveLogDrain mints the pass's writer, inside RunRecovery only, and hands |
| 47 | // it to hook. The hook receives the writer, never the minting context. |
| 48 | func runMoveLogDrain(ctx context.Context, hook func(context.Context, *MoveLogWriter) error) error { |
| 49 | w := recoveryWriter(ctx) |
| 50 | if w == nil || hook == nil { |
| 51 | return ErrContract |
| 52 | } |
| 53 | lw := &MoveLogWriter{w: w, state: &moveRecoveryGrantState{active: true}} |
| 54 | state := lw.state |
| 55 | defer func() { state.mu.Lock(); state.active = false; state.mu.Unlock() }() |
| 56 | ctx = context.WithValue(ctx, lifecycleGrantKey{}, []lifecycleGrant(nil)) |
| 57 | ctx = context.WithValue(ctx, moveRecoveryPassKey{}, (*moveRecoveryPassScope)(nil)) |
| 58 | return hook(ctx, lw) |
| 59 | } |
| 60 | |
| 61 | // WriteMoveLog writes one entry, with the checks platform-1e set for the |
| 62 | // grant's Log, which this writer replaced (fix round 3, ruling 3): |
| 63 | // |
| 64 | // - The host's claim holder is e's incarnation (fix round 4, ruling 3): a |
| 65 | // recreated instance (the same names, another incarnation), a retired |
| 66 | // one (no claim) or another app's host never takes the entry. The |
| 67 | // account and app are the holder's. |
| 68 | // - The move is one the holder's record names: live at that incarnation, |
| 69 | // or closed as its last move. The caller's move id is never taken alone. |
| 70 | // - The key is derived, never given: e.Name under the report prefix of the |
| 71 | // holder's account and app, e's host and move. The move id and the entry |
| 72 | // name are one segment of letters, digits, '_' and '-'. |
| 73 | // - Body is at most MoveLogEntryMax bytes and decodes, strictly, as a |
| 74 | // moves.LogEvent of that move and host, named e.Name. |
| 75 | // - The write is create-only. A replay of the same bytes is a no-op |
| 76 | // (created false); other bytes under the name are ErrMoveLogTaken. |
| 77 | // |
| 78 | // ErrIdentity is an entry that can never be written. It answers ErrPending |
| 79 | // once its hook has returned. |
| 80 | func (lw *MoveLogWriter) WriteMoveLog(ctx context.Context, e MoveLogEntry) (created bool, err error) { |
| 81 | if lw == nil || lw.state == nil || lw.w == nil { |
| 82 | return false, ErrPending |
| 83 | } |
| 84 | lw.state.mu.Lock() |
| 85 | defer lw.state.mu.Unlock() |
| 86 | if !lw.state.active { |
| 87 | return false, ErrPending |
| 88 | } |
| 89 | if e.Incarnation == "" || !moveLogName(e.Op) || !moveLogName(e.Name) || len(e.Body) > MoveLogEntryMax { |
| 90 | return false, ErrIdentity |
| 91 | } |
| 92 | if _, err := hostclaim.Key(e.Host); err != nil { |
| 93 | return false, ErrIdentity |
| 94 | } |
| 95 | var ev moves.LogEvent |
| 96 | if err := strictJSON(e.Body, &ev); err != nil { |
| 97 | return false, ErrIdentity |
| 98 | } |
| 99 | if ev.V != 1 || ev.Op != e.Op || ev.Host != e.Host || ev.Identity != e.Name { |
| 100 | return false, ErrIdentity |
| 101 | } |
| 102 | o, err := hostclaim.Read(ctx, lw.w.raw, e.Host) |
| 103 | if err != nil { |
| 104 | return false, err |
| 105 | } |
| 106 | if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.Claim.Holder.Incarnation != e.Incarnation { |
| 107 | return false, ErrIdentity |
| 108 | } |
| 109 | h := o.Claim.Holder |
| 110 | if !controlname.ValidAccount(h.Account) || !controlname.ValidApp(h.App) { |
| 111 | return false, ErrIdentity |
| 112 | } |
| 113 | r, err := ReadRecord(ctx, lw.w.raw, h.Key) |
| 114 | if err != nil { |
| 115 | return false, err |
| 116 | } |
| 117 | if !matches(r, h) { |
| 118 | return false, ErrIdentity |
| 119 | } |
| 120 | rec, err := provision.DecodeRecord(r.Body) |
| 121 | if err != nil { |
| 122 | return false, ErrIdentity |
| 123 | } |
| 124 | live := rec.Move != nil && rec.Move.ID == e.Op && rec.Move.Incarnation == e.Incarnation |
| 125 | closed := rec.LastMove != nil && rec.LastMove.ID == e.Op |
| 126 | if !live && !closed { |
| 127 | return false, ErrIdentity |
| 128 | } |
| 129 | key := moves.LogKeyFor(moves.ReportPrefix(h.Account, h.App, e.Host, e.Op), e.Name) |
| 130 | if _, err := lw.w.raw.Put(ctx, key, e.Body, ""); err == nil { |
| 131 | return true, nil |
| 132 | } else if !errors.Is(err, release.ErrCASConflict) { |
| 133 | return false, err |
| 134 | } |
| 135 | have, _, err := lw.w.raw.Get(ctx, key) |
| 136 | if err != nil { |
| 137 | return false, err |
| 138 | } |
| 139 | if !bytes.Equal(have, e.Body) { |
| 140 | return false, ErrMoveLogTaken |
| 141 | } |
| 142 | return false, nil |
| 143 | } |
| 144 | |
| 145 | // moveLogName is one path segment of letters, digits, '_' and '-', at most |
| 146 | // 128 bytes: every identity the move log names (moves.TransitionOf) fits. |
| 147 | func moveLogName(name string) bool { |
| 148 | if name == "" || len(name) > 128 { |
| 149 | return false |
| 150 | } |
| 151 | for _, r := range name { |
| 152 | if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') { |
| 153 | return false |
| 154 | } |
| 155 | } |
| 156 | return true |
| 157 | } |
| 158 | |