package reconcile import ( "context" "encoding/json" "fmt" "time" "gitea.dooplex.hu/admin/felhom-agent/internal/authz" "gitea.dooplex.hu/admin/felhom-agent/internal/proxmox" ) // DestructiveExecutor performs an authorized destructive op against the host. At slice // 4 there is NO live implementation (guest-destroy / storage-wipe / restore-overwrite // executors land in slices 6/7) — the consuming layer is wired and tested with fixture // executors but never executes a real destructive op, because nothing serves // destructive deltas until slice 10. It returns a Proxmox UPID (or "" for a synchronous // op) so the journal/Recover path is identical to benign execution. type DestructiveExecutor func(ctx context.Context, intent Intent, vop *authz.VerifiedOp) (upid string, err error) // JobResult is the outcome of RunSignedJob. type JobResult struct { Decision Decision AlreadyApplied bool // the op's idempotency key was already applied (deduped, not re-run) Executed bool // the executor ran and succeeded Err error // execution error (after a successful authorization) } // RunSignedJob is the signed one-shot consuming layer (doc 03 §4(b) / doc 04). It adds // idempotency dedupe + journaling around the gate: // // 1. Dedupe: if the op's idempotency key (its nonce) is already applied, skip — a // redelivered, already-completed op must not re-run (returns AlreadyApplied). // 2. Gate: classify + verify + role-scope + op-to-action bind. A refusal returns the // Decision and executes nothing. // 3. Journal + execute: record started → run the executor → record the task id → // record the terminal state under the idempotency key (so success marks the key // applied; a crash mid-execute is resolved by Recover, never by idempotency alone). // // exec may be nil — then an AUTHORIZED destructive op is journaled as authorized but // not executed (the slice-4 inert state: the gate works, the executor doesn't exist // yet). A REFUSED op never reaches exec. func (e *Engine) RunSignedJob(ctx context.Context, intent Intent, signed *SignedOp, exec DestructiveExecutor) JobResult { idemKey := jobIdempotencyKey(signed) // 1. Idempotency dedupe (redelivery after a prior success). if idemKey != "" && e.journal != nil && e.journal.AlreadyApplied(idemKey) { e.logger.Info("job: idempotency key already applied; skipping", "key", auditNonce(idemKey)) return JobResult{AlreadyApplied: true, Decision: Decision{Allowed: true, Reason: ReasonSigned}} } // 2. Gate (classification + the full signed-op consuming policy). dec := e.gate.Authorize(intent, signed) if !dec.Allowed { return JobResult{Decision: dec} } // 3. Journal + execute. Benign authorized ops (no signature path) also flow here if // routed as jobs; they carry no idempotency key and are simply executed. opID := e.nextJobOpID(intent) e.append(JournalEntry{OpID: opID, VMID: intent.VMID, Kind: string(intent.Class), State: OpStarted, IdempKey: idemKey, At: time.Now().UTC()}) if exec == nil { // Slice-4 inert: authorized, but no destructive executor wired. Record the // authorization terminally (do NOT mark applied — nothing actually ran). e.append(JournalEntry{OpID: opID, VMID: intent.VMID, Kind: string(intent.Class), State: OpFailed, IdempKey: "", At: time.Now().UTC()}) e.logger.Warn("job: authorized but no executor wired (slice-4 inert)", "class", intent.Class) return JobResult{Decision: dec, Err: fmt.Errorf("reconcile: no executor for %s (not wired this slice)", intent.Class)} } upid, err := exec(ctx, intent, dec.Verified) if err != nil { e.append(JournalEntry{OpID: opID, VMID: intent.VMID, Kind: string(intent.Class), State: OpFailed, IdempKey: idemKey, At: time.Now().UTC()}) return JobResult{Decision: dec, Err: err} } e.append(JournalEntry{OpID: opID, VMID: intent.VMID, Kind: string(intent.Class), UPID: upid, State: OpTaskRunning, IdempKey: idemKey, At: time.Now().UTC()}) if upid != "" { st, err := e.api.WaitTask(ctx, upid, proxmox.WaitOptions{}) if err != nil { e.append(JournalEntry{OpID: opID, VMID: intent.VMID, Kind: string(intent.Class), UPID: upid, State: OpFailed, IdempKey: idemKey, At: time.Now().UTC()}) return JobResult{Decision: dec, Err: err} } _ = st } // Terminal success — marks the idempotency key applied (survives restart). e.append(JournalEntry{OpID: opID, VMID: intent.VMID, Kind: string(intent.Class), UPID: upid, State: OpSucceeded, IdempKey: idemKey, At: time.Now().UTC()}) return JobResult{Decision: dec, Executed: true} } // jobIdempotencyKey derives the idempotency key from the signed op's nonce — unique // per op (≥128-bit, doc 04 §2.1) and already the anti-replay token, so reusing it as // the journal dedupe key is exact. Parsed from the UNVERIFIED blob: it is only a map // key here (the gate's verifier is the trust boundary), and a forged blob is refused at // the gate regardless. func jobIdempotencyKey(signed *SignedOp) string { if signed == nil || len(signed.Blob) == 0 { return "" } var b struct { Nonce string `json:"nonce"` } if json.Unmarshal(signed.Blob, &b) != nil { return "" } return b.Nonce } // nextJobOpID builds a per-attempt op id for a signed job (distinct namespace from // reconcile op ids). func (e *Engine) nextJobOpID(intent Intent) string { return "job-" + string(intent.Class) + "-" + intent.GuestID + "-" + nextSeq(&e.opSeq) }