1/**
2 * Checks whether cluster runners should be treated as alive.
3 *
4 * `RunnerHealth` is used by sharding when deciding whether assigned shards can
5 * stay on a runner or need to move elsewhere. This module includes the
6 * health-check service, a no-op layer that always reports runners as alive, a
7 * ping-based checker, and a Kubernetes-based checker that looks at pod readiness
8 * for the runner host.
9 *
10 * @stability unstable
11 * @since 4.0.0
12 */
13import * as Context from "../Context.ts"
14import * as Effect from "../Effect.ts"
15import * as Layer from "../Layer.ts"
16import * as Schedule from "../Schedule.ts"
17import type * as Scope from "../Scope.ts"
18import * as K8s from "./K8sHttpClient.ts"
19import type { RunnerAddress } from "./RunnerAddress.ts"
20import * as Runners from "./Runners.ts"
21
22/**
23 * Represents the service used to check if a Runner is healthy.
24 *
25 * **Details**
26 *
27 * If a Runner is responsive, shards will not be re-assigned because the Runner may
28 * still be processing messages. If a Runner is not responsive, then its
29 * associated shards can and will be re-assigned to a different Runner.
30 *
31 * @stability unstable
32 * @category services
33 * @since 4.0.0
34 */
35export class RunnerHealth extends Context.Service<
36 RunnerHealth,
37 {
38 readonly isAlive: (address: RunnerAddress) => Effect.Effect<boolean>
39 }
40>()("effect/cluster/RunnerHealth") {}
41
42/**
43 * Layer that always considers a runner healthy.
44 *
45 * **When to use**
46 *
47 * Use when you need a runner-health layer for tests or local development where
48 * active health checks are unnecessary.
49 *
50 * @stability unstable
51 * @category layers
52 * @since 4.0.0
53 */
54export const layerNoop = Layer.succeed(RunnerHealth, {
55 isAlive: () => Effect.succeed(true)
56})
57
58/**
59 * Creates a `RunnerHealth` service that pings runners through `Runners`, retrying
60 * failed pings on a short schedule and treating a successful ping within the
61 * timeout as healthy.
62 *
63 * @stability unstable
64 * @category constructors
65 * @since 4.0.0
66 */
67export const makePing: Effect.Effect<
68 RunnerHealth["Service"],
69 never,
70 Runners.Runners | Scope.Scope
71> = Effect.gen(function*() {
72 const runners = yield* Runners.Runners
73 const schedule = Schedule.spaced(500)
74
75 function isAlive(address: RunnerAddress): Effect.Effect<boolean> {
76 return runners.ping(address).pipe(
77 Effect.timeout(10_000),
78 Effect.retry({ times: 5, schedule }),
79 Effect.isSuccess
80 )
81 }
82
83 return RunnerHealth.of({ isAlive })
84})
85
86/**
87 * Layer that pings runners directly to check whether they are healthy.
88 *
89 * @stability unstable
90 * @category layers
91 * @since 4.0.0
92 */
93export const layerPing: Layer.Layer<
94 RunnerHealth,
95 never,
96 Runners.Runners
97> = Layer.effect(RunnerHealth, makePing)
98
99/**
100 * Creates a `RunnerHealth` service that checks Kubernetes pod readiness for a
101 * runner host, optionally scoped by namespace and label selector.
102 *
103 * **Gotchas**
104 *
105 * If the Kubernetes API check fails, the runner is treated as healthy.
106 *
107 * @stability unstable
108 * @category constructors
109 * @since 4.0.0
110 */
111export const makeK8s = Effect.fnUntraced(function*(options?: {
112 readonly namespace?: string | undefined
113 readonly labelSelector?: string | undefined
114}) {
115 const allPods = yield* K8s.makeGetPods(options)
116
117 return RunnerHealth.of({
118 isAlive: (address) =>
119 allPods.pipe(
120 Effect.map((pods) => pods.get(address.host)?.isReadyOrInitializing ?? false),
121 Effect.catchCause(() => Effect.succeed(true))
122 )
123 })
124})
125
126/**
127 * Layer that checks Kubernetes pod readiness to determine whether a runner is
128 * healthy.
129 *
130 * **Details**
131 *
132 * The provided `HttpClient` must trust the pod CA certificate and the pod service
133 * account must be allowed to list pods.
134 *
135 * **Gotchas**
136 *
137 * If the Kubernetes API check fails, the runner is treated as healthy.
138 *
139 * @stability unstable
140 * @category layers
141 * @since 4.0.0
142 */
143export const layerK8s = (
144 options?: {
145 readonly namespace?: string | undefined
146 readonly labelSelector?: string | undefined
147 } | undefined
148): Layer.Layer<
149 RunnerHealth,
150 never,
151 K8s.K8sHttpClient
152> => Layer.effect(RunnerHealth, makeK8s(options))