// External-socket advisory (issue #17): an exec-shaped route whose socket // is served by a process the activator does not manage. Advisory only, // like adopt_advice.go — log lines for the operator, never a mutation. // Lives in the AGENT, not the root adopt pass, because the only honest // answer to "did the activator spawn the thing serving this socket" is // the activator's in-memory entry table, which RunAdopt cannot see. package host import ( "context" "errors" "log" "math/rand/v2" "net" "os" "time" "github.com/carlosframework/platform/internal/activator" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/unixdial" ) // warnExternalSockets returns the in-scope routes whose socket is being // SERVED by something the activator does not manage. In scope is the // spawnable-exec shape, mirroring appconfig's execSyncable: instance, // has an app, non-platform, ready, non-unit backing, socket upstream. // Addr-upstream routes (issue #18) are externally managed BY DESIGN and // never warn; unit-backed routes belong to the #14 advisory. The dial // runs before the owns check so a spawn racing the sweep reads as owned, // not stray. func warnExternalSockets(routes []registry.Route, serving func(socket string) bool, owns func(host string) bool) []registry.Route { var warned []registry.Route for _, rt := range routes { if rt.Kind != "instance" || rt.App == "" || rt.Platform || (rt.Status != "" && rt.Status != "ready") || rt.Backing == registry.BackingUnit || rt.Socket == "" { continue } if !serving(rt.Socket) { continue } if rt.Hibernate && owns(rt.Host) { continue // the activator's own child, in whatever state } warned = append(warned, rt) } return warned } // socketServing: the real probe. A live listener answers the dial; a // stale socket file nobody listens on refuses it — only the former is // "served". Same-box unix accept, so the timeout is generous. // // A path-mismatch refusal (the socket is a symlink to some OTHER // listener, or otherwise resolves to a path unixdial won't vouch for) is // logged rather than silently folded into "not served": the difference // between "nothing answers this socket" and "something answers it, but // not honestly" is exactly what an operator chasing issue #17 needs to // see named, not dropped on the floor. func socketServing(socket string) bool { ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) defer cancel() c, err := unixdial.Checked(ctx, &net.Dialer{}, socket) if err != nil { if errors.Is(err, unixdial.ErrPeerMismatch) { log.Printf("socket advice: %s: %v", socket, err) } return false } c.Close() return true } // advisorySocketServing is the advisory's probe with generations on. A // managed instance's canonical path is then the activator's own symlink to // its current generation (zero-downtime spec §7.1), which socketServing's // checked dial would report as a peer mismatch every hour. A link naming // the host's current generation is the activator's and is not a stray; // every other path is probed as before. appSocket is the activator's. func advisorySocketServing(routes []registry.Route, appSocket func(host string) (string, bool), serving func(string) bool) func(string) bool { hostOf := map[string]string{} for _, r := range routes { if r.Kind == "instance" && r.Hibernate && r.Socket != "" && r.Backing != registry.BackingUnit { hostOf[r.Socket] = r.Host } } return func(sock string) bool { if host, ok := hostOf[sock]; ok { if cur, ok := appSocket(host); ok { if target, err := os.Readlink(sock); err == nil && target == cur { return false } } } return serving(sock) } } // socketAdvisoryLoop runs the advisory hourly, first pass ~1min after // boot, like its sibling loops. svc may be nil (activator unarmed): // owns is then constantly false and every live socket on an exec route // warns — correctly, nothing on this box manages them. Needs no env // arming: it only reads the registry and dials sockets. func socketAdvisoryLoop(reg *registry.Registry, svc *activator.Service) { owns := func(host string) bool { return svc != nil && svc.Owns(host) } log.Printf("agent: socket advisory armed (hourly + jitter)") delay := time.Minute for { time.Sleep(delay) delay = time.Hour + rand.N(10*time.Minute) routes, err := reg.List() if err != nil { log.Printf("agent: socket advisory: list routes: %v", err) continue } serving := socketServing if svc != nil && svc.GenerationsOn() { serving = advisorySocketServing(routes, svc.AppSocket, socketServing) } for _, rt := range warnExternalSockets(routes, serving, owns) { if rt.Hibernate { log.Printf("agent: route %s is activator-managed (hibernate=1) but its socket %s is served by a process the activator did not spawn — a stray supervisor is squatting the socket and will fight the wake path; find and retire it (see platform issue #17)", rt.Host, rt.Socket) } else { log.Printf("agent: route %s is exec-shaped with hibernate=0 and its socket %s is live — an external process serves it, the activator never engages, and spawn-time config delivery (/data/%s.config.env) never reaches it; retire the external supervisor and set hibernate=1 once the app speaks the activation contract (see platform issue #17)", rt.Host, rt.Socket, rt.Host) } } } }