carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "errors"
6 "flag"
7 "io"
8 "log"
9 "net/http"
10 "os"
11 "os/signal"
12 "path/filepath"
13 "runtime"
14 "strings"
15 "syscall"
16 "time"
17
18 "github.com/carlosframework/platform/internal/hostwrite"
19 "github.com/carlosframework/platform/internal/lbhealth"
20 "github.com/carlosframework/platform/internal/status"
21 "github.com/carlosframework/platform/internal/version"
22)
23
24// The agent's joining mode (region HA spec 2 §5 step 4) and the gate that
25// chooses it (joinmode.go).
26//
27// A joining box publishes its signed identity record (boxRecordLoop) and,
28// once its guard is armed, its guard-ready report: the private status
29// report every hosting box publishes, carrying the guard's facts
30// (claimReport). It never publishes an edges file: a box an edges file
31// names is referenced, and automatic enrollment refuses a referenced box
32// (hostactors references.go, enroll.go). It opens no registry, starts no
33// activator and serves no instance. Its only listener is the load
34// balancer's health check on the edge's HTTP address, which answers 503:
35// lbhealth.Default is never marked hosting here.
36//
37// When the box is invited it asks root to arm the guard by touching
38// joinRequestMarker (carlos-system-join.path). Once a start check finds it
39// enrolled and armed, it returns, and RunAgent ends the process so systemd
40// starts the agent again, now as the claim agent through the guard.
41
42// joinEvery is how often joining checks its mode.
43const joinEvery = 15 * time.Second
44
45// joinNudgeEvery is how often joining re-publishes its report or re-asks
46// root, at most: the report stays well inside reportFreshness, and root
47// is not asked again while it is still arming. While the box stays
48// invited with its guard not armed, root is asked again at this interval,
49// once a minute: carlos-system-join.path fires only when the marker
50// changes, so a carlos system join that failed (an IMDS or store read,
51// say) is retried by the next touch, and by nothing else.
52const joinNudgeEvery = time.Minute
53
54// joinRequestMarker is the file carlos-system-join.path watches, under the
55// data dir.
56const joinRequestMarker = "system-join-requested"
57
58// joining is one joining run's steps; production's are productionJoining.
59type joining struct {
60 facts modeFacts
61 every time.Duration
62 // serve answers the load balancer until ctx ends.
63 serve func(ctx context.Context) error
64 // record publishes the box's identity record, retrying until it is in
65 // place.
66 record func(ctx context.Context)
67 // report publishes the guard-ready report.
68 report func(ctx context.Context) error
69 // requestArm asks root to arm the guard.
70 requestArm func() error
71 logf func(format string, args ...any)
72}
73
74// run joins until the box can host (the decision is returned) or ctx
75// ends (the last decision, and ctx's error).
76func (j joining) run(ctx context.Context) (modeDecision, error) {
77 lbhealth.Default.SetHosting(false)
78 go func() {
79 if err := j.serve(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
80 j.logf("agent: joining: the load balancer health check is not answered: %v", err)
81 }
82 }()
83 go j.record(ctx)
84 var last modeDecision
85 var lastErr string
86 var nudged time.Time
87 for {
88 d, err := selectMode(ctx, j.facts)
89 now := j.facts.now()
90 switch {
91 case err != nil:
92 if err.Error() != lastErr {
93 j.logf("agent: joining: %v; checking again", err)
94 lastErr = err.Error()
95 }
96 case d.Mode == modeHosting:
97 j.logf("agent: joining: %s is enrolled and its guard is armed; restarting as the claim agent", d.Box)
98 return d, nil
99 default:
100 lastErr = ""
101 if d.Stage != last.Stage || d.Why != last.Why {
102 j.logf("agent: joining: %s: %s %s", d.Box, d.Stage, d.Why)
103 nudged = time.Time{}
104 }
105 last = d
106 if now.Sub(nudged) >= joinNudgeEvery {
107 switch d.Stage {
108 case stageInvited:
109 if err := j.requestArm(); err != nil {
110 j.logf("agent: joining: could not ask root to arm the guard: %v", err)
111 }
112 case stageGuardInstalled, stageGuardReady:
113 if err := j.report(ctx); err != nil {
114 j.logf("agent: joining: the guard-ready report was not published: %v", err)
115 }
116 }
117 nudged = now
118 }
119 }
120 select {
121 case <-ctx.Done():
122 return last, ctx.Err()
123 case <-time.After(j.every):
124 }
125 }
126}
127
128// productionJoining is joining on a real box. args are the agent's own
129// (edge.Run's flags): the health check listens on -http, as the edge will.
130func productionJoining(args []string, facts modeFacts, getenv func(string) string) joining {
131 addr := joiningHTTPAddr(args)
132 return joining{
133 facts: facts,
134 every: joinEvery,
135 serve: func(ctx context.Context) error {
136 srv := &http.Server{Addr: addr, ReadHeaderTimeout: 10 * time.Second, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
137 if !lbhealth.Serve(w, r, lbhealth.Default, time.Now()) {
138 http.NotFound(w, r)
139 }
140 })}
141 go func() { <-ctx.Done(); _ = srv.Close() }()
142 return srv.ListenAndServe()
143 },
144 record: func(context.Context) { boxRecordLoop() },
145 report: func(ctx context.Context) error {
146 store, _, err := openStatusStore(ctx, getenv)
147 if err != nil {
148 return err
149 }
150 p := status.Probes{MachineID: readMachineID(), InstanceID: cachedInstanceID(), Region: strings.TrimSpace(getenv("CARLOS_REGION")),
151 Release: version.Build, Platform: runtime.GOOS + "-" + runtime.GOARCH, SystemChannel: boxSystemChannel(getenv)}
152 claimReport(&p)
153 box := status.CollectBox(nil, p, time.Now())
154 if groupReferenced(getenv) {
155 // Under the group reference the console derives (census
156 // ruling region-ha-2b), as statusLoop publishes it.
157 box.Label = facts.box
158 }
159 return status.WriteBox(ctx, store, box)
160 },
161 requestArm: func() error {
162 return os.WriteFile(filepath.Join(dataDir(), joinRequestMarker), []byte(time.Now().UTC().Format(time.RFC3339Nano)+"\n"), 0o644)
163 },
164 logf: log.Printf,
165 }
166}
167
168// joiningHTTPAddr is the agent's -http flag, ":80" when it is not given.
169func joiningHTTPAddr(args []string) string {
170 fs := flag.NewFlagSet("agent", flag.ContinueOnError)
171 fs.SetOutput(io.Discard)
172 addr := fs.String("http", ":80", "")
173 fs.String("registry", "", "")
174 fs.String("acme-cache", "", "")
175 fs.String("https", "", "")
176 fs.String("fallback", "", "")
177 fs.Bool("dev", false, "")
178 _ = fs.Parse(args)
179 return *addr
180}
181
182// decideAgentMode is RunAgent's gate. With claim mode or region HA off
183// there is no joining mode, and the agent hosts as on main. Otherwise it
184// selects the mode, waiting out an unknown one, and on joining runs join
185// until the box can host or ctx ends; it reports whether this process
186// should go on to start the claim agent.
187func decideAgentMode(ctx context.Context, getenv func(string) string, open func(context.Context, func(string) string) (modeFacts, error), join func(context.Context, modeFacts, modeDecision) error, wait time.Duration, logf func(string, ...any)) bool {
188 if !hostwrite.Enabled(getenv) || !regionHAEnabled(getenv) {
189 return true
190 }
191 var last string
192 note := func(err error) bool {
193 if err.Error() != last {
194 logf("agent: mode: %v; checking again", err)
195 last = err.Error()
196 }
197 select {
198 case <-ctx.Done():
199 return false
200 case <-time.After(wait):
201 return true
202 }
203 }
204 for {
205 facts, err := open(ctx, getenv)
206 if err != nil {
207 if !note(modeUnknown("the deployment's stores and this box's guard", err)) {
208 return false
209 }
210 continue
211 }
212 d, err := selectMode(ctx, facts)
213 if err != nil {
214 if !note(err) {
215 return false
216 }
217 continue
218 }
219 if d.Mode == modeHosting {
220 return true
221 }
222 logf("agent: %s is joining (%s): it hosts nothing until it is enrolled", d.Box, d.Stage)
223 if err := join(ctx, facts, d); err != nil && !errors.Is(err, context.Canceled) {
224 logf("agent: joining: %v", err)
225 }
226 return false
227 }
228}
229
230// hostingGate decides, for RunAgent, whether this process starts the claim
231// agent, and records the answer in agentHosting. SIGTERM or SIGINT while
232// joining ends the process cleanly.
233func hostingGate(args []string) bool {
234 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
235 defer stop()
236 hosting := decideAgentMode(ctx, os.Getenv, productionModeFacts, func(ctx context.Context, facts modeFacts, _ modeDecision) error {
237 _, err := productionJoining(args, facts, os.Getenv).run(ctx)
238 return err
239 }, joinEvery, log.Printf)
240 agentHosting.Store(hosting)
241 return hosting
242}
243
244// groupReferenceBackoff is how long agentEntry waits after the n-th
245// failed read of a group box's reference (n from 1): a second, doubling,
246// at most 30 seconds.
247func groupReferenceBackoff(n int) time.Duration {
248 d := time.Second
249 for ; n > 1 && d < 30*time.Second; n-- {
250 d *= 2
251 }
252 return min(d, 30*time.Second)
253}
254
255// agentEntry is RunAgent's order. A box its region's group launched (the
256// CARLOS_BOX_REFERENCE=group marker) first resolves its group reference,
257// whatever the claim and region HA switches say: metricsBoxLabel answers
258// "" until it resolves, and the loops runAgent starts (the edges file,
259// the registry snapshot, metrics, usage, the layout box, the moves pass)
260// read the label once and keep it for the process. It asks IMDS again
261// with backoff, logging each failure's cause, until it answers or the
262// process is told to stop. Any other box resolves nothing. Then the gate
263// (hostingGate) decides whether this process starts the agent (run).
264func agentEntry(getenv func(string) string, resolve func() (string, error), backoff func(int) time.Duration, logf func(string, ...any), gate func() bool, run func()) {
265 if groupReferenced(getenv) {
266 ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
267 ok := awaitGroupReference(ctx, resolve, backoff, logf)
268 stop()
269 if !ok {
270 return
271 }
272 }
273 if !gate() {
274 return
275 }
276 run()
277}
278
279// awaitGroupReference asks resolve until it answers, waiting backoff(n)
280// after the n-th failure; false when ctx ends first.
281func awaitGroupReference(ctx context.Context, resolve func() (string, error), backoff func(int) time.Duration, logf func(string, ...any)) bool {
282 for n := 1; ; n++ {
283 ref, err := resolve()
284 if err == nil {
285 logf("agent: this box's group reference is %s", ref)
286 return true
287 }
288 wait := backoff(n)
289 logf("agent: this box's group reference could not be read from its identity document (attempt %d): %v; the agent starts once it is, asking again in %s", n, err, wait)
290 select {
291 case <-ctx.Done():
292 return false
293 case <-time.After(wait):
294 }
295 }
296}
297