package host import ( "bytes" "context" "encoding/json" "errors" "fmt" "github.com/carlosframework/platform/internal/rootstate" "io" "log" "net" "net/http" "os" "path/filepath" "time" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostclaim" execlease "github.com/carlosframework/platform/internal/hostexec" "github.com/carlosframework/platform/internal/hostguard" "github.com/carlosframework/platform/internal/instancectl" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" ) // ClaimRootHTTP is instance-control's root claim handler. startupBinding is // this box's binding in root's startup roster read (the cleanup identity's): // a pending inventory or rebind intent stamped with another binding is // obsolete and is settled, never replayed (roster binding N3). Empty skips // that check. type ClaimRootHTTP struct { servingParks *servingParkRuntime inventoryIdentity func(*os.File) (rootstate.FileIdentity, error) retireBoundUnit func(context.Context, hostguard.FenceTarget) error history rootHistoryAccess executionRuntime *claimExecutionRuntime Execution *execlease.RootBroker InventoryActor func(context.Context) (hostbox.Actor, error) RequireNewWork func(context.Context) error Journals *hostbox.JournalService Launches *hostbox.LaunchJournal Stop ClaimStopHTTP Reconcile hostbox.ReconcileAdapter Roots map[string]string startupBinding string } func (h ClaimRootHTTP) Start(ctx context.Context) { if h.executionRuntime != nil { h.executionRuntime.Start(ctx) } h.servingParks.Start(ctx) } func (h ClaimRootHTTP) RecoverPending(ctx context.Context) (recoveryErr error) { // Controller recovery is independent of per-host retained journals and parks. if h.Execution != nil { recoveryErr = h.Execution.Recover(ctx) } recoveryErr = errors.Join(recoveryErr, h.Stop.RecoverPending(ctx)) recoveryErr = errors.Join(recoveryErr, h.servingHandover().Recover(ctx)) // Startup resumes a journaled cold handover only through the fresh // successor admission (B1). One whose successor was stamped under a // binding other than the startup binding is settled instead (B3). cold := h.coldHandover() cold.Obsolete = func(next hostbox.ColdSuccessor) bool { if !h.obsoleteIntent(next.DirectoryRevision) { return false } log.Printf("instance-control: %s: obsolete cold handover: stamped %s current %s", next.Host, next.DirectoryRevision, h.startupBinding) return true } recoveryErr = errors.Join(recoveryErr, cold.Recover(ctx)) journals, err := hostbox.SavedRetirementJournals(h.Reconcile.Retirement.JournalDir) recoveryErr = errors.Join(recoveryErr, err) for _, journal := range journals { recoveryErr = errors.Join(recoveryErr, h.retire(ctx, *journal.Target)) } recoveryErr = errors.Join(recoveryErr, h.recoverRootInventory(ctx)) recoveryErr = errors.Join(recoveryErr, h.servingParks.recoverPending(ctx)) return errors.Join(recoveryErr, ctx.Err()) } func (h ClaimRootHTTP) ServeHTTP(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/claim/execution" && r.URL.Path != "/claim/recovery-rebind" && r.URL.Path != "/claim/retire" && r.URL.Path != "/claim/handover" && r.URL.Path != "/claim/journal" { h.Stop.ServeHTTP(w, r) return } if r.Method != http.MethodPost || h.Stop.Authenticate == nil { http.Error(w, "refused", http.StatusServiceUnavailable) return } conn := instancectl.RequestConn(r.Context()) if conn == nil { http.Error(w, "refused", http.StatusForbidden) return } peer, err := h.Stop.Authenticate(r.Context(), conn) if err != nil { http.Error(w, "refused", http.StatusForbidden) return } if peer.UID == 0 || peer.PID <= 0 || peer.ProcessStart == "" || peer.BootID == "" || peer.ControllerID == "" { http.Error(w, "refused", http.StatusForbidden) return } if r.URL.Path == "/claim/execution" { h.serveExecution(w, r, peer) return } if r.URL.Path == "/claim/recovery-rebind" { h.serveRecoveryRebind(w, r) return } if r.URL.Path == "/claim/journal" { h.serveJournal(w, r, peer) return } if r.URL.Path == "/claim/handover" { if h.RequireNewWork != nil { if err := h.RequireNewWork(r.Context()); err != nil { http.Error(w, "refused", http.StatusForbidden) return } } h.serveColdHandover(w, r, peer) return } var target hostguard.FenceTarget decoder := json.NewDecoder(io.LimitReader(r.Body, 32<<10)) decoder.DisallowUnknownFields() if decoder.Decode(&target) != nil || decoder.Decode(new(any)) != io.EOF || target.Box != h.Reconcile.Box || target.Enrollment != h.Reconcile.Enrollment { http.Error(w, "refused", http.StatusBadRequest) return } if target.BootID != peer.BootID || target.ControllerID != peer.ControllerID { // A restarted controller may carry the exact completed target produced // for its predecessor. Authenticate still proves the live caller; the // local root's immutable receipt proves the old tuple. Never rewrite the // target to the caller's tuple. receipt, e := h.Stop.Coordinator.Guard.FenceReceiptFor(r.Context(), target) if e != nil || verifyRetirementReceipt(r.Context(), receipt) != nil { http.Error(w, "refused", http.StatusBadRequest) return } } if err = h.retire(r.Context(), target); err != nil { http.Error(w, "pending", http.StatusConflict) return } w.WriteHeader(http.StatusNoContent) } func (h ClaimRootHTTP) retire(ctx context.Context, target hostguard.FenceTarget) error { a := h.Reconcile if a.Inventory == nil || a.Registry == nil || !filepath.IsAbs(a.Retirement.JournalDir) { return hostbox.ErrUnsupported } in, err := a.Inventory.ReadExactStopInventory(ctx, target) if err != nil { return err } if !in.Complete || in.Host != target.Host || in.Incarnation != target.Incarnation || in.InstallationStamp != target.Stamp || in.Box != target.Box || in.Enrollment != target.Enrollment || in.DirectoryRevision != target.DirectoryRevision { return hostbox.ErrTypedInventory } var row registry.Route for _, e := range in.Entries { if e.Kind == "route" && e.ManagedRoot == "registry" && e.RelativePath == target.Host && e.Materialization == "materialized" { if row.Host != "" { return hostbox.ErrTypedInventory } row = registry.Route{Host: target.Host, ClaimOwnership: registry.ClaimOwnership{ClaimIncarnation: target.Incarnation, ClaimStamp: target.Stamp, ClaimOwner: e.Ownership, ClaimRowID: e.RowIdentity}} } } if row.Host == "" || !row.ClaimOwnership.Valid() { return hostbox.ErrTypedInventory } a.RemoteRetire = nil a.DataRoot = h.Roots["data"] a.Box = target.Box a.Reconciler.Guard = h.Stop.Coordinator.Guard a.RequireTimerInventory = a.RequireTimerInventory || a.Reconciler.Guard.Root != nil a.Stop = func(context.Context, registry.Route) (hostguard.FenceTarget, error) { return target, nil } a.Retirement.Filesystem = hostbox.TypedParker{CapturePersistent: h.inventoryIdentity != nil, Roots: h.Roots, ParkedRoots: h.Roots, Incarnation: target.Incarnation, Host: target.Host, Retirement: target.FenceID} a.Retirement.Authority = hostbox.InstallationCleanup{Guard: a.Reconciler.Guard, Verify: verifyRetirementReceipt} if h.retireBoundUnit != nil { a.Retirement.Authority = claimBoundUnitCleanup{inner: a.Retirement.Authority, retire: h.retireBoundUnit} } a.Retirement.Ack = retirementAcknowledgement(h.Stop.Coordinator.Evidence) return a.Retire(ctx, row) } func verifyRetirementReceipt(_ context.Context, receipt hostguard.FenceReceipt) error { var s StopReceipt if json.Unmarshal(receipt.Bytes, &s) != nil || !bytes.Equal(receipt.Bytes, mustMarshalStopReceipt(s)) { return ErrClaimStopReceipt } t := receipt.Target if s.Kind != "stopped" || s.Region != t.Region || s.ClaimETag != t.ClaimETag || s.ClaimGen != t.ClaimGen || s.ObligationID != t.ObligationID || s.FenceID != t.FenceID || s.Host != t.Host || s.Incarnation != t.Incarnation || s.InstallationStamp != t.Stamp || s.WriterSession != t.Session || s.OperationID != t.Op || s.Phase != t.Phase || s.Box != t.Box || s.Enrollment != t.Enrollment || s.BootID != t.BootID || s.ControllerID != t.ControllerID || s.Binding != t.DirectoryRevision || !s.EvidenceAt.Equal(receipt.EvidenceAt) { return ErrClaimStopReceipt } if numeral, err := stopReceiptNumeral(t); err != nil || s.DirectoryRevision != numeral { return ErrClaimStopReceipt } return nil } func retirementAcknowledgement(store release.Store) func(context.Context, hostbox.RetirementJournal) error { return func(ctx context.Context, j hostbox.RetirementJournal) error { if store == nil || !j.Complete || j.Target == nil || len(j.StopReceipt) == 0 { return ErrClaimStopReceipt } var receipt StopReceipt if json.Unmarshal(j.StopReceipt, &receipt) != nil || receipt.ObligationID == "" || receipt.OperationID == "" { return ErrClaimStopReceipt } if err := verifyRetirementReceipt(ctx, hostguard.FenceReceipt{Target: *j.Target, Bytes: j.StopReceipt, EvidenceAt: receipt.EvidenceAt}); err != nil { return err } body, err := json.Marshal(j) if err != nil { return err } key := fmt.Sprintf("control/hosts/%s/evidence/%s/%s/%s-retirement.json", receipt.Host, receipt.OperationID, receipt.Box, receipt.ID) if _, err = store.Put(ctx, key, body, ""); err != nil { if !errors.Is(err, release.ErrCASConflict) { return err } previous, _, e := store.Get(ctx, key) if e != nil { return e } if !bytes.Equal(previous, body) { return ErrClaimStopReceipt } } for attempt := 0; attempt < 8; attempt++ { observed, err := hostclaim.Read(ctx, store, receipt.Host) if err != nil { return err } if observed.Absent || observed.Claim == nil { return ErrClaimStopIdentity } found := false for _, ob := range observed.Claim.Obligations { if ob.ID == receipt.ObligationID && ob.Op == receipt.OperationID { if ob.Kind != "park-local" || ob.Box != receipt.Box || ob.Owner != "box:"+receipt.Box { return ErrClaimStopIdentity } found = true } } if !found { return nil } _, err = hostclaim.Update(ctx, store, observed, hostclaim.CompleteObligation(receipt.OperationID, receipt.ObligationID)) if err == nil { return nil } if !errors.Is(err, release.ErrCASConflict) { return err } } return release.ErrCASConflict } } func RetireClaimOverSocket(ctx context.Context, socket string, target hostguard.FenceTarget) error { if socket == "" || !filepath.IsAbs(socket) { return ErrClaimStopUnavailable } body, err := json.Marshal(target) if err != nil { return err } transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", socket) }} defer transport.CloseIdleConnections() req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://instance-control/claim/retire", bytes.NewReader(body)) if err != nil { return err } response, err := (&http.Client{Transport: transport, Timeout: 2 * time.Minute}).Do(req) if err != nil { return err } defer response.Body.Close() if response.StatusCode != http.StatusNoContent { return ErrClaimStopReceipt } return nil }