package host import ( "context" "encoding/json" "errors" "fmt" "os" "path/filepath" "time" "github.com/carlosframework/platform/internal/rootparticipation" "github.com/carlosframework/platform/internal/hostfloor" ) // Executable updates and claim acquisition share root's transaction lock. // The root marker persists through the update journal's terminal state. // Agent files may add a refusal, but never establish an idle census. const ( updateBegunFile = "update-begun.json" hostexecDir = ".hostexec" ) // systemGuardDir is /opt/carlos/system/guard, beside root's update state. func systemGuardDir(optDir string) string { return filepath.Join(optDir, "system", "guard") } func updateBegunPath(optDir string) string { return filepath.Join(systemGuardDir(optDir), updateBegunFile) } type updateBegun struct { Target string `json:"target"` PromotedAt time.Time `json:"promoted_at"` At time.Time `json:"at"` } // beginExecutableChange is §5.10's root side, run under the apply lock // just before the first box-changing step: write the marker durably, then // look for participation. On a refusal the marker is removed again and the // error, wrapping errSystemDeferred, says why. The caller records it as // "deferred", never "refused": a refusal is terminal for that promote, and // this one must be retried once the operation ends (spec §5.10: the // update is refused and retried later). The agent's leg re-requests a // deferred apply on its backoff ladder, at most every five minutes. func (a *systemApplier) beginExecutableChange(ctx context.Context, target string, promotedAt time.Time) error { required, err := a.participationDisk().ParticipationRequired(ctx) if err != nil { return err } if !required && !a.regionHA { return nil } active, err := a.activeFloor() if err != nil { return err } if active { // beginGuard already persisted the reservation ID. Retain it until // authenticated completion, including when participation appears late. tx, err := a.guardTransaction() if err != nil { return err } raw, err := os.ReadFile(filepath.Join(a.floor.Dir, updateBegunFile)) if err != nil { return err } var begun struct { ID string `json:"id"` } if json.Unmarshal(raw, &begun) != nil || begun.ID != tx.ID { return hostfloor.ErrFloor } if err := a.controllersIdle(ctx); err != nil { return fmt.Errorf("%w: %v", errSystemDeferred, err) } return nil } if !a.regionHA { return a.participationDisk().WithParticipation(ctx, func(ctx context.Context, l rootparticipation.Ledger) error { return l.BeginUpdate(ctx, "system-"+target) }, func() error { return nil }) } writeMarker := func() error { if err := os.MkdirAll(systemGuardDir(a.optDir), 0o755); err != nil { return err } return writeJSONDurable(updateBegunPath(a.optDir), updateBegun{Target: target, PromotedAt: promotedAt, At: a.ops.now().UTC()}, 0o644) } err = a.participationDisk().BeginParticipation(ctx, "system-"+target, a.dataDir, writeMarker, func() error { if err := writeMarker(); err != nil { return err } if reason := a.participationDisk().CheckParticipation(ctx, a.dataDir, false); reason != nil { if err := a.clearLegacyUpdateBegun(); err != nil { a.ops.logf("system apply: %v", err) } return reason } return nil }) if err != nil { return fmt.Errorf("%w: %v", errSystemDeferred, err) } return nil } // clearUpdateBegun removes both markers only after positive root settlement. func (a *systemApplier) clearUpdateBegun(ctx context.Context) error { return a.participationDisk().WithParticipation(ctx, func(ctx context.Context, l rootparticipation.Ledger) error { id, err := l.UpdateID() if errors.Is(err, os.ErrNotExist) { if _, e := os.Lstat(updateBegunPath(a.optDir)); errors.Is(e, os.ErrNotExist) { return nil } id = "recover-legacy" err = l.BeginUpdate(ctx, id) } if err != nil { return err } return l.EndUpdateWith(ctx, id, func() error { if _, e := os.Lstat(updateBegunPath(a.optDir)); errors.Is(e, os.ErrNotExist) { return nil } return hostfloor.RemoveDurable(updateBegunPath(a.optDir)) }) }, a.clearLegacyUpdateBegun) } func (a *systemApplier) clearLegacyUpdateBegun() error { err := os.Remove(updateBegunPath(a.optDir)) if errors.Is(err, os.ErrNotExist) { return nil } if err != nil { return fmt.Errorf("remove %s: %w", updateBegunPath(a.optDir), err) } return syncDir(systemGuardDir(a.optDir)) } // settleUpdateBegun clears the marker once no update is in progress: no // journal means the update finished, rolled back, was recovered, or never // got past its first step. A kept journal (a rollback that could not // finish) keeps the marker with it. Run with or without CARLOS_REGION_HA, // so a marker left behind after the flag is turned off still goes. // // On a census box the settlement goes through settleWithoutJournal (Astra // round 3): a legacy marker with no update record and no journal has // clearUpdateBegun begin root's update record, and a census refusal there // withdrew it and left acquisitions open while the error was only logged. // Now a census refusal comes back as errSettlementDeferred with the // "recover-legacy" barrier held, and a barrier that could not be written as // errAcquisitionBarrier; the caller decides what each means for the run. // Every other failure is logged and swallowed, as on main. func (a *systemApplier) settleUpdateBegun(ctx context.Context) error { if active, err := a.activeFloor(); err != nil || active { return nil } if j, err := readSystemJournal(a.optDir); err != nil || j != nil { return nil } err := a.settleWithoutJournal(ctx, "recover-legacy", a.clearUpdateBegun(ctx)) if err == nil { return nil } if errors.Is(err, errSettlementDeferred) || errors.Is(err, errAcquisitionBarrier) { return err } a.ops.logf("system apply: %v", err) return nil } // closeGuardRollback is closeGuardJournal for the backward paths: rollback // and backward recovery. On a floor-armed box it first records in root's // marker that the transaction rolled back, before the journal goes, so the // completion that follows is recorded for an image that is not the target // (Opus re-review M1-4). A marker for another transaction refuses and keeps // the journal. With no armed floor this is closeGuardJournal, as on main. func (a *systemApplier) closeGuardRollback(ctx context.Context) error { active, err := a.activeFloor() if err != nil { return err } if active { tx, err := a.guardTransaction() if err != nil { return err } // No marker means none was begun, and settleGuard then records no // completion: the reservation stays held, which is the safe side. if err := hostfloor.MarkRolledBack(a.floor.Dir, tx.ID); err != nil && !errors.Is(err, os.ErrNotExist) { return err } } return a.closeGuardJournal(ctx) } func (a *systemApplier) participationDisk() hostfloor.Disk { if a.floor != nil { return *a.floor } return hostfloor.Disk{Participation: a.participation} } func (a *systemApplier) checkUnarmedParticipationRecovery(ctx context.Context) error { required, err := a.participationDisk().ParticipationRequired(ctx) if err != nil || !required { return err } j, err := readSystemJournal(a.optDir) if err != nil { return err } recover, reserve := false, false err = a.participationDisk().WithParticipation(ctx, func(ctx context.Context, l rootparticipation.Ledger) error { c, err := l.RecoveryCensus() if err != nil { return err } if c.Pending == "acquisition" { recover = true } _, err = l.UpdateID() if errors.Is(err, os.ErrNotExist) { if j == nil { return nil } reserve = true } else if err != nil { return err } recover = true return nil }, func() error { return nil }) if err != nil { return err } if recover { if err := a.controllersRecovered(ctx); err != nil { if j == nil { err = a.settleWithoutJournal(ctx, "recover-system", err) } return err } } if reserve { return a.participationDisk().WithParticipation(ctx, func(ctx context.Context, l rootparticipation.Ledger) error { return l.BeginUpdate(ctx, "recover-system") }, func() error { return nil }) } return nil }