package hostops import ( "bytes" "context" "errors" "github.com/carlosframework/platform/internal/controlname" "github.com/carlosframework/platform/internal/hostclaim" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/release" ) // The recovery pass's move log writer (Plan 6 Task 3, fix round 3, ruling 1). // The console queues the move log entries its move pass makes in its outbox // (move_log_outbox) and delivers them through this writer, once per recovery // pass (Recovery.MoveLogs), whatever the moves have done since: a grant is // spent by the time its transition's entry exists, and the claim's release // never waits for the log. The writer exposes no store: one create-only entry // per call, under the entry's own move's report prefix. // MoveLogEntryMax bounds one entry the writer writes, in bytes. const MoveLogEntryMax = 4096 // ErrMoveLogTaken is an entry whose name already holds other bytes: the same // transition, logged first by another writer. Nothing is written. var ErrMoveLogTaken = errors.New("hostops: the move log entry was written by another writer") // MoveLogEntry is one move log entry to deliver: the move's host and id, the // instance incarnation the move's evidence carried, the entry's name (its // identity) and its exact bytes. It names no account or app: those are the // host's claim's, once its holder is that incarnation. type MoveLogEntry struct { Host, Op, Incarnation, Name string Body []byte } // MoveLogWriter is one recovery pass's move log writer, valid only while its // hook runs. type MoveLogWriter struct { w *ControlWriter state *moveRecoveryGrantState } // runMoveLogDrain mints the pass's writer, inside RunRecovery only, and hands // it to hook. The hook receives the writer, never the minting context. func runMoveLogDrain(ctx context.Context, hook func(context.Context, *MoveLogWriter) error) error { w := recoveryWriter(ctx) if w == nil || hook == nil { return ErrContract } lw := &MoveLogWriter{w: w, state: &moveRecoveryGrantState{active: true}} state := lw.state defer func() { state.mu.Lock(); state.active = false; state.mu.Unlock() }() ctx = context.WithValue(ctx, lifecycleGrantKey{}, []lifecycleGrant(nil)) ctx = context.WithValue(ctx, moveRecoveryPassKey{}, (*moveRecoveryPassScope)(nil)) return hook(ctx, lw) } // WriteMoveLog writes one entry, with the checks platform-1e set for the // grant's Log, which this writer replaced (fix round 3, ruling 3): // // - The host's claim holder is e's incarnation (fix round 4, ruling 3): a // recreated instance (the same names, another incarnation), a retired // one (no claim) or another app's host never takes the entry. The // account and app are the holder's. // - The move is one the holder's record names: live at that incarnation, // or closed as its last move. The caller's move id is never taken alone. // - The key is derived, never given: e.Name under the report prefix of the // holder's account and app, e's host and move. The move id and the entry // name are one segment of letters, digits, '_' and '-'. // - Body is at most MoveLogEntryMax bytes and decodes, strictly, as a // moves.LogEvent of that move and host, named e.Name. // - The write is create-only. A replay of the same bytes is a no-op // (created false); other bytes under the name are ErrMoveLogTaken. // // ErrIdentity is an entry that can never be written. It answers ErrPending // once its hook has returned. func (lw *MoveLogWriter) WriteMoveLog(ctx context.Context, e MoveLogEntry) (created bool, err error) { if lw == nil || lw.state == nil || lw.w == nil { return false, ErrPending } lw.state.mu.Lock() defer lw.state.mu.Unlock() if !lw.state.active { return false, ErrPending } if e.Incarnation == "" || !moveLogName(e.Op) || !moveLogName(e.Name) || len(e.Body) > MoveLogEntryMax { return false, ErrIdentity } if _, err := hostclaim.Key(e.Host); err != nil { return false, ErrIdentity } var ev moves.LogEvent if err := strictJSON(e.Body, &ev); err != nil { return false, ErrIdentity } if ev.V != 1 || ev.Op != e.Op || ev.Host != e.Host || ev.Identity != e.Name { return false, ErrIdentity } o, err := hostclaim.Read(ctx, lw.w.raw, e.Host) if err != nil { return false, err } if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.Claim.Holder.Incarnation != e.Incarnation { return false, ErrIdentity } h := o.Claim.Holder if !controlname.ValidAccount(h.Account) || !controlname.ValidApp(h.App) { return false, ErrIdentity } r, err := ReadRecord(ctx, lw.w.raw, h.Key) if err != nil { return false, err } if !matches(r, h) { return false, ErrIdentity } rec, err := provision.DecodeRecord(r.Body) if err != nil { return false, ErrIdentity } live := rec.Move != nil && rec.Move.ID == e.Op && rec.Move.Incarnation == e.Incarnation closed := rec.LastMove != nil && rec.LastMove.ID == e.Op if !live && !closed { return false, ErrIdentity } key := moves.LogKeyFor(moves.ReportPrefix(h.Account, h.App, e.Host, e.Op), e.Name) if _, err := lw.w.raw.Put(ctx, key, e.Body, ""); err == nil { return true, nil } else if !errors.Is(err, release.ErrCASConflict) { return false, err } have, _, err := lw.w.raw.Get(ctx, key) if err != nil { return false, err } if !bytes.Equal(have, e.Body) { return false, ErrMoveLogTaken } return false, nil } // moveLogName is one path segment of letters, digits, '_' and '-', at most // 128 bytes: every identity the move log names (moves.TransitionOf) fits. func moveLogName(name string) bool { if name == "" || len(name) > 128 { return false } for _, r := range name { if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') { return false } } return true }