| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "log" |
| 9 | "net" |
| 10 | "net/http" |
| 11 | "strings" |
| 12 | "time" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/moves" |
| 15 | "github.com/carlosframework/platform/internal/registry" |
| 16 | ) |
| 17 | |
| 18 | // S1 (moves spec §7.3, codex 14; Task 14 review finding 4): the instance as |
| 19 | // it runs today answers its readiness path from its own process. The probe |
| 20 | // wakes a hibernating instance through the activator (ServeScheduled: the |
| 21 | // same wake, no activity credit) and then asks the instance itself over its |
| 22 | // own socket. Only that answer counts: anything the activator, the edge, a |
| 23 | // gate or a fallback writes in its place is not the instance answering. |
| 24 | |
| 25 | // s1Timeout bounds one probe, wake included (§7.7's 30 s readiness window). |
| 26 | const s1Timeout = 30 * time.Second |
| 27 | |
| 28 | // s1BodyLimit is the most of a readiness answer the probe reads; a longer |
| 29 | // body fails, since it was not read in full. |
| 30 | const s1BodyLimit = 1 << 20 |
| 31 | |
| 32 | // probeInstance is S1 for rt at path. wake is the activator's entry |
| 33 | // (Service.ServeScheduled in production): it wakes the instance and hands |
| 34 | // the request to the handler it is given, which asks the instance over |
| 35 | // rt.Socket. A wake that answers itself (a fenced or refused host, a |
| 36 | // fallback) never reaches that handler. A route the activator does not |
| 37 | // manage (wake returns false) is asked directly. S1 passes only on a 2xx |
| 38 | // from the instance, its body read in full, within the deadline. |
| 39 | func probeInstance(ctx context.Context, wake func(http.ResponseWriter, *http.Request, registry.Route, http.Handler) bool, rt registry.Route, path string) moves.Check { |
| 40 | fail := moves.Check{Reason: s1NoAnswer(rt.Host)} |
| 41 | ctx, cancel := context.WithTimeout(ctx, s1Timeout) |
| 42 | defer cancel() |
| 43 | if !strings.HasPrefix(path, "/") { |
| 44 | path = "/" + path |
| 45 | } |
| 46 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+rt.Host+path, nil) |
| 47 | if err != nil { |
| 48 | return fail |
| 49 | } |
| 50 | req.Host = rt.Host |
| 51 | req.RemoteAddr = "127.0.0.1:0" |
| 52 | var answered bool |
| 53 | var status int |
| 54 | var askErr error |
| 55 | ask := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 56 | answered = true |
| 57 | status, askErr = askInstance(r.Context(), rt.Socket, r) |
| 58 | if askErr == nil { |
| 59 | w.WriteHeader(status) |
| 60 | } |
| 61 | }) |
| 62 | rec := &probeRecorder{header: http.Header{}} |
| 63 | if !wake(rec, req, rt, ask) { |
| 64 | ask.ServeHTTP(rec, req) |
| 65 | } |
| 66 | switch { |
| 67 | case !answered, askErr != nil, ctx.Err() != nil: |
| 68 | if askErr != nil { |
| 69 | log.Printf("moves: %s: S1: %v", rt.Host, askErr) |
| 70 | } |
| 71 | return fail |
| 72 | case status < 200 || status > 299: |
| 73 | return fail |
| 74 | } |
| 75 | return moves.Check{OK: true} |
| 76 | } |
| 77 | |
| 78 | // askInstance sends r to the instance listening on socket and reads its |
| 79 | // whole answer, never following a redirect. |
| 80 | func askInstance(ctx context.Context, socket string, r *http.Request) (int, error) { |
| 81 | if socket == "" { |
| 82 | return 0, errors.New("the route names no socket") |
| 83 | } |
| 84 | client := &http.Client{ |
| 85 | Transport: &http.Transport{ |
| 86 | DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| 87 | var d net.Dialer |
| 88 | return d.DialContext(ctx, "unix", socket) |
| 89 | }, |
| 90 | DisableKeepAlives: true, |
| 91 | }, |
| 92 | CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, |
| 93 | } |
| 94 | // The declared target as declared: the URL keeps RawPath, so an |
| 95 | // escaped "/ready%2Fcheck" is never sent as "/ready/check". |
| 96 | u := *r.URL |
| 97 | u.Scheme, u.Host = "http", r.Host |
| 98 | out, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) |
| 99 | if err != nil { |
| 100 | return 0, err |
| 101 | } |
| 102 | out.Host = r.Host |
| 103 | resp, err := client.Do(out) |
| 104 | if err != nil { |
| 105 | return 0, err |
| 106 | } |
| 107 | defer resp.Body.Close() |
| 108 | n, err := io.Copy(io.Discard, io.LimitReader(resp.Body, s1BodyLimit+1)) |
| 109 | if err != nil { |
| 110 | return 0, err |
| 111 | } |
| 112 | if n > s1BodyLimit { |
| 113 | return 0, fmt.Errorf("the answer is over %d bytes", s1BodyLimit) |
| 114 | } |
| 115 | return resp.StatusCode, nil |
| 116 | } |
| 117 | |
| 118 | // probeRecorder is the ResponseWriter the wake writes to. What it records |
| 119 | // is never evidence: only askInstance's answer is. |
| 120 | type probeRecorder struct { |
| 121 | header http.Header |
| 122 | status int |
| 123 | } |
| 124 | |
| 125 | func (r *probeRecorder) Header() http.Header { return r.header } |
| 126 | func (r *probeRecorder) Write(b []byte) (int, error) { |
| 127 | if r.status == 0 { |
| 128 | r.status = http.StatusOK |
| 129 | } |
| 130 | return len(b), nil |
| 131 | } |
| 132 | func (r *probeRecorder) WriteHeader(code int) { |
| 133 | if r.status == 0 { |
| 134 | r.status = code |
| 135 | } |
| 136 | } |
| 137 | |