carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
7package host
8
9import (
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.
31func 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.
60func 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.
80func 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.
104func 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