| 1 | // External-socket advisory (issue #17): an exec-shaped route whose socket |
| 2 | // is served by a process the activator does not manage. Advisory only, |
| 3 | // like adopt_advice.go — log lines for the operator, never a mutation. |
| 4 | // Lives in the AGENT, not the root adopt pass, because the only honest |
| 5 | // answer to "did the activator spawn the thing serving this socket" is |
| 6 | // the activator's in-memory entry table, which RunAdopt cannot see. |
| 7 | package host |
| 8 | |
| 9 | import ( |
| 10 | "context" |
| 11 | "errors" |
| 12 | "log" |
| 13 | "math/rand/v2" |
| 14 | "net" |
| 15 | "os" |
| 16 | "time" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/activator" |
| 19 | "github.com/carlosframework/platform/internal/registry" |
| 20 | "github.com/carlosframework/platform/internal/unixdial" |
| 21 | ) |
| 22 | |
| 23 | // warnExternalSockets returns the in-scope routes whose socket is being |
| 24 | // SERVED by something the activator does not manage. In scope is the |
| 25 | // spawnable-exec shape, mirroring appconfig's execSyncable: instance, |
| 26 | // has an app, non-platform, ready, non-unit backing, socket upstream. |
| 27 | // Addr-upstream routes (issue #18) are externally managed BY DESIGN and |
| 28 | // never warn; unit-backed routes belong to the #14 advisory. The dial |
| 29 | // runs before the owns check so a spawn racing the sweep reads as owned, |
| 30 | // not stray. |
| 31 | func warnExternalSockets(routes []registry.Route, serving func(socket string) bool, owns func(host string) bool) []registry.Route { |
| 32 | var warned []registry.Route |
| 33 | for _, rt := range routes { |
| 34 | if rt.Kind != "instance" || rt.App == "" || rt.Platform || |
| 35 | (rt.Status != "" && rt.Status != "ready") || |
| 36 | rt.Backing == registry.BackingUnit || rt.Socket == "" { |
| 37 | continue |
| 38 | } |
| 39 | if !serving(rt.Socket) { |
| 40 | continue |
| 41 | } |
| 42 | if rt.Hibernate && owns(rt.Host) { |
| 43 | continue // the activator's own child, in whatever state |
| 44 | } |
| 45 | warned = append(warned, rt) |
| 46 | } |
| 47 | return warned |
| 48 | } |
| 49 | |
| 50 | // socketServing: the real probe. A live listener answers the dial; a |
| 51 | // stale socket file nobody listens on refuses it — only the former is |
| 52 | // "served". Same-box unix accept, so the timeout is generous. |
| 53 | // |
| 54 | // A path-mismatch refusal (the socket is a symlink to some OTHER |
| 55 | // listener, or otherwise resolves to a path unixdial won't vouch for) is |
| 56 | // logged rather than silently folded into "not served": the difference |
| 57 | // between "nothing answers this socket" and "something answers it, but |
| 58 | // not honestly" is exactly what an operator chasing issue #17 needs to |
| 59 | // see named, not dropped on the floor. |
| 60 | func socketServing(socket string) bool { |
| 61 | ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) |
| 62 | defer cancel() |
| 63 | c, err := unixdial.Checked(ctx, &net.Dialer{}, socket) |
| 64 | if err != nil { |
| 65 | if errors.Is(err, unixdial.ErrPeerMismatch) { |
| 66 | log.Printf("socket advice: %s: %v", socket, err) |
| 67 | } |
| 68 | return false |
| 69 | } |
| 70 | c.Close() |
| 71 | return true |
| 72 | } |
| 73 | |
| 74 | // advisorySocketServing is the advisory's probe with generations on. A |
| 75 | // managed instance's canonical path is then the activator's own symlink to |
| 76 | // its current generation (zero-downtime spec §7.1), which socketServing's |
| 77 | // checked dial would report as a peer mismatch every hour. A link naming |
| 78 | // the host's current generation is the activator's and is not a stray; |
| 79 | // every other path is probed as before. appSocket is the activator's. |
| 80 | func advisorySocketServing(routes []registry.Route, appSocket func(host string) (string, bool), serving func(string) bool) func(string) bool { |
| 81 | hostOf := map[string]string{} |
| 82 | for _, r := range routes { |
| 83 | if r.Kind == "instance" && r.Hibernate && r.Socket != "" && r.Backing != registry.BackingUnit { |
| 84 | hostOf[r.Socket] = r.Host |
| 85 | } |
| 86 | } |
| 87 | return func(sock string) bool { |
| 88 | if host, ok := hostOf[sock]; ok { |
| 89 | if cur, ok := appSocket(host); ok { |
| 90 | if target, err := os.Readlink(sock); err == nil && target == cur { |
| 91 | return false |
| 92 | } |
| 93 | } |
| 94 | } |
| 95 | return serving(sock) |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | // socketAdvisoryLoop runs the advisory hourly, first pass ~1min after |
| 100 | // boot, like its sibling loops. svc may be nil (activator unarmed): |
| 101 | // owns is then constantly false and every live socket on an exec route |
| 102 | // warns — correctly, nothing on this box manages them. Needs no env |
| 103 | // arming: it only reads the registry and dials sockets. |
| 104 | func socketAdvisoryLoop(reg *registry.Registry, svc *activator.Service) { |
| 105 | owns := func(host string) bool { return svc != nil && svc.Owns(host) } |
| 106 | log.Printf("agent: socket advisory armed (hourly + jitter)") |
| 107 | delay := time.Minute |
| 108 | for { |
| 109 | time.Sleep(delay) |
| 110 | delay = time.Hour + rand.N(10*time.Minute) |
| 111 | routes, err := reg.List() |
| 112 | if err != nil { |
| 113 | log.Printf("agent: socket advisory: list routes: %v", err) |
| 114 | continue |
| 115 | } |
| 116 | serving := socketServing |
| 117 | if svc != nil && svc.GenerationsOn() { |
| 118 | serving = advisorySocketServing(routes, svc.AppSocket, socketServing) |
| 119 | } |
| 120 | for _, rt := range warnExternalSockets(routes, serving, owns) { |
| 121 | if rt.Hibernate { |
| 122 | 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) |
| 123 | } else { |
| 124 | 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) |
| 125 | } |
| 126 | } |
| 127 | } |
| 128 | } |
| 129 | |