carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1// App-internal instance visibility: apps MAY expose GET /.carlos/instances
2// on their public host (the contract is INSTANCES.md, next to this file) and
3// the console renders what they report. The fetch is deliberately meek — a
4// short timeout, a small size cap, a brief cache — because an app that is
5// slow, asleep, or silent must cost the app page nothing but this section.
6package console
7
8import (
9 "context"
10 "encoding/json"
11 "io"
12 "net/http"
13 "strings"
14 "sync"
15 "time"
16)
17
18const (
19 // InstancesPath is the well-known path of the instance-report contract.
20 InstancesPath = "/.carlos/instances"
21 // instancesTimeout bounds one report fetch: past it the app is simply
22 // "not reporting" — the page never hangs on a slow tenant.
23 instancesTimeout = 1500 * time.Millisecond
24 // instancesTTL is how long a fetched report (or a failure) is reused
25 // before the host is asked again.
26 instancesTTL = 30 * time.Second
27 // instancesMaxBody caps the response body read; bigger is "not reporting".
28 instancesMaxBody = 64 << 10
29 // instancesMaxCount caps how many instances one report may list.
30 instancesMaxCount = 100
31)
32
33// Instance is one row of an app's self-report. Slug is required; everything
34// else is optional color. Unknown JSON fields are ignored.
35type Instance struct {
36 Slug string `json:"slug"`
37 Status string `json:"status"`
38 Region string `json:"region"`
39 Detail string `json:"detail"`
40}
41
42// InstanceReport is what the app page renders: which host was asked and what
43// it said. Reporting is false when the host was polled but didn't answer the
44// contract (no endpoint, an error, a timeout, junk) — the page then renders
45// exactly as it did before this feature existed.
46type InstanceReport struct {
47 Host string
48 Instances []Instance
49 Reporting bool
50}
51
52// instanceFetcher polls apps for their reports through their public hosts,
53// caching per host so page reloads don't drum on tenants.
54type instanceFetcher struct {
55 client *http.Client
56 scheme string
57 timeout time.Duration
58
59 mu sync.Mutex
60 cache map[string]instanceCacheEntry
61}
62
63type instanceCacheEntry struct {
64 report InstanceReport
65 fetched time.Time
66}
67
68func newInstanceFetcher(client *http.Client, scheme string) *instanceFetcher {
69 if client == nil {
70 client = &http.Client{Timeout: instancesTimeout}
71 }
72 if scheme == "" {
73 scheme = "https"
74 }
75 return &instanceFetcher{
76 client: client,
77 scheme: scheme,
78 timeout: instancesTimeout,
79 cache: map[string]instanceCacheEntry{},
80 }
81}
82
83// Report picks the app's reporting host and fetches (or reuses) its report.
84// The host is the first ready, non-static, non-sidecar route that has NOT
85// opted into hibernation — polling a hibernatable host could wake it, and
86// the console must never wake an app just to draw a table. A sidecar route
87// is excluded for a different reason: its Host is a derived
88// .sidecar.internal label nothing can dial (design §4.1), so choosing it
89// would make a HEALTHY app read "not reporting" purely because the picker
90// landed on an unroutable row from the same app's route set (Assemble groups
91// by app NAME with no kind filter). nil means no route is pollable, which
92// renders the same as "not reporting".
93func (f *instanceFetcher) Report(ctx context.Context, routes []RouteView) *InstanceReport {
94 for _, rt := range routes {
95 if rt.Host == "" || rt.Kind == "static" || rt.Kind == "sidecar" || rt.Status != "ready" || rt.Hibernate {
96 continue
97 }
98 rep := f.get(ctx, rt.Host)
99 return &rep
100 }
101 return nil
102}
103
104func (f *instanceFetcher) get(ctx context.Context, host string) InstanceReport {
105 now := time.Now()
106 f.mu.Lock()
107 if e, ok := f.cache[host]; ok && now.Sub(e.fetched) < instancesTTL {
108 f.mu.Unlock()
109 return e.report
110 }
111 f.mu.Unlock()
112 rep := f.fetch(ctx, host)
113 f.mu.Lock()
114 f.cache[host] = instanceCacheEntry{report: rep, fetched: now}
115 f.mu.Unlock()
116 return rep
117}
118
119// fetch asks one host once. Every failure mode — connect error, non-200,
120// oversized body, invalid JSON — folds to Reporting=false; the contract is
121// opt-in and silence is a fine answer.
122func (f *instanceFetcher) fetch(ctx context.Context, host string) InstanceReport {
123 rep := InstanceReport{Host: host}
124 ctx, cancel := context.WithTimeout(ctx, f.timeout)
125 defer cancel()
126 req, err := http.NewRequestWithContext(ctx, http.MethodGet, f.scheme+"://"+host+InstancesPath, nil)
127 if err != nil {
128 return rep
129 }
130 res, err := f.client.Do(req)
131 if err != nil {
132 return rep
133 }
134 defer res.Body.Close()
135 if res.StatusCode != http.StatusOK {
136 return rep
137 }
138 body, err := io.ReadAll(io.LimitReader(res.Body, instancesMaxBody+1))
139 if err != nil || len(body) > instancesMaxBody {
140 return rep
141 }
142 var raw []Instance
143 if err := json.Unmarshal(body, &raw); err != nil {
144 return rep
145 }
146 var out []Instance
147 for _, in := range raw {
148 if strings.TrimSpace(in.Slug) == "" {
149 continue // slug is the one required field; slugless rows are noise
150 }
151 out = append(out, in)
152 if len(out) == instancesMaxCount {
153 break
154 }
155 }
156 rep.Instances, rep.Reporting = out, true
157 return rep
158}
159