@effect/cluster 0.53.2 → 0.53.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,240 @@
1
+ /**
2
+ * @since 1.0.0
3
+ */
4
+ import * as FileSystem from "@effect/platform/FileSystem"
5
+ import * as HttpClient from "@effect/platform/HttpClient"
6
+ import type * as HttpClientError from "@effect/platform/HttpClientError"
7
+ import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
8
+ import * as HttpClientResponse from "@effect/platform/HttpClientResponse"
9
+ import * as Context from "effect/Context"
10
+ import * as Effect from "effect/Effect"
11
+ import { identity } from "effect/Function"
12
+ import * as Layer from "effect/Layer"
13
+ import * as Option from "effect/Option"
14
+ import type * as ParseResult from "effect/ParseResult"
15
+ import * as Schedule from "effect/Schedule"
16
+ import * as Schema from "effect/Schema"
17
+ import type * as v1 from "kubernetes-types/core/v1.d.ts"
18
+
19
+ /**
20
+ * @since 1.0.0
21
+ * @category Tags
22
+ */
23
+ export class K8sHttpClient extends Context.Tag("@effect/cluster/K8sHttpClient")<
24
+ K8sHttpClient,
25
+ HttpClient.HttpClient
26
+ >() {}
27
+
28
+ /**
29
+ * @since 1.0.0
30
+ * @category Layers
31
+ */
32
+ export const layer: Layer.Layer<
33
+ K8sHttpClient,
34
+ never,
35
+ HttpClient.HttpClient | FileSystem.FileSystem
36
+ > = Layer.effect(
37
+ K8sHttpClient,
38
+ Effect.gen(function*() {
39
+ const fs = yield* FileSystem.FileSystem
40
+ const token = yield* fs.readFileString("/var/run/secrets/kubernetes.io/serviceaccount/token").pipe(
41
+ Effect.option
42
+ )
43
+ return (yield* HttpClient.HttpClient).pipe(
44
+ HttpClient.mapRequest(HttpClientRequest.prependUrl("https://kubernetes.default.svc/api")),
45
+ token._tag === "Some" ? HttpClient.mapRequest(HttpClientRequest.bearerToken(token.value.trim())) : identity,
46
+ HttpClient.filterStatusOk,
47
+ HttpClient.retryTransient({
48
+ schedule: Schedule.spaced(5000)
49
+ })
50
+ )
51
+ })
52
+ )
53
+
54
+ /**
55
+ * @since 1.0.0
56
+ * @category Constructors
57
+ */
58
+ export const makeGetPods: (
59
+ options?: {
60
+ readonly namespace?: string | undefined
61
+ readonly labelSelector?: string | undefined
62
+ } | undefined
63
+ ) => Effect.Effect<
64
+ Effect.Effect<Map<string, Pod>, HttpClientError.HttpClientError | ParseResult.ParseError, never>,
65
+ never,
66
+ K8sHttpClient
67
+ > = Effect.fnUntraced(function*(options?: {
68
+ readonly namespace?: string | undefined
69
+ readonly labelSelector?: string | undefined
70
+ }) {
71
+ const client = yield* K8sHttpClient
72
+
73
+ const getPods = HttpClientRequest.get(
74
+ options?.namespace ? `/v1/namespaces/${options.namespace}/pods` : "/v1/pods"
75
+ ).pipe(
76
+ HttpClientRequest.setUrlParam("fieldSelector", "status.phase=Running"),
77
+ options?.labelSelector ? HttpClientRequest.setUrlParam("labelSelector", options.labelSelector) : identity
78
+ )
79
+
80
+ return yield* client.execute(getPods).pipe(
81
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(PodList)),
82
+ Effect.map((list) => {
83
+ const pods = new Map<string, Pod>()
84
+ for (let i = 0; i < list.items.length; i++) {
85
+ const pod = list.items[i]
86
+ pods.set(pod.status.podIP, pod)
87
+ }
88
+ return pods
89
+ }),
90
+ Effect.tapErrorCause((cause) => Effect.logWarning("Failed to fetch pods from Kubernetes API", cause)),
91
+ Effect.cachedWithTTL("10 seconds")
92
+ )
93
+ })
94
+
95
+ /**
96
+ * @since 1.0.0
97
+ * @category Constructors
98
+ */
99
+ export const makeCreatePod = Effect.gen(function*() {
100
+ const client = yield* K8sHttpClient
101
+
102
+ return Effect.fnUntraced(function*(spec: v1.Pod) {
103
+ spec = {
104
+ apiVersion: "v1",
105
+ kind: "Pod",
106
+ metadata: {
107
+ namespace: "default",
108
+ ...spec.metadata
109
+ },
110
+ ...spec
111
+ }
112
+ const namespace = spec.metadata?.namespace ?? "default"
113
+ const name = spec.metadata!.name!
114
+ const readPodRaw = HttpClientRequest.get(`/v1/namespaces/${namespace}/pods/${name}`).pipe(
115
+ client.execute
116
+ )
117
+ const readPod = readPodRaw.pipe(
118
+ Effect.flatMap(HttpClientResponse.schemaBodyJson(Pod)),
119
+ Effect.asSome,
120
+ Effect.retry({
121
+ while: (e) => e._tag === "ParseError",
122
+ schedule: Schedule.spaced("1 seconds")
123
+ }),
124
+ Effect.catchIf((err) => err._tag === "ResponseError" && err.response.status === 404, () => Effect.succeedNone),
125
+ Effect.orDie
126
+ )
127
+ const isPodFound = readPodRaw.pipe(
128
+ Effect.as(true),
129
+ Effect.catchIf(
130
+ (err) => err._tag === "ResponseError" && err.response.status === 404,
131
+ () => Effect.succeed(false)
132
+ )
133
+ )
134
+ const createPod = HttpClientRequest.post(`/v1/namespaces/${namespace}/pods`).pipe(
135
+ HttpClientRequest.bodyUnsafeJson(spec),
136
+ client.execute,
137
+ Effect.catchIf(
138
+ (err) => err._tag === "ResponseError" && err.response.status === 409,
139
+ () => readPod
140
+ ),
141
+ Effect.tapErrorCause(Effect.logInfo),
142
+ Effect.orDie
143
+ )
144
+ const deletePod = HttpClientRequest.del(`/v1/namespaces/${namespace}/pods/${name}`).pipe(
145
+ client.execute,
146
+ Effect.flatMap((res) => res.json),
147
+ Effect.catchIf(
148
+ (err) => err._tag === "ResponseError" && err.response.status === 404,
149
+ () => Effect.void
150
+ ),
151
+ Effect.tapErrorCause(Effect.logInfo),
152
+ Effect.orDie,
153
+ Effect.asVoid
154
+ )
155
+ yield* Effect.addFinalizer(Effect.fnUntraced(function*() {
156
+ yield* deletePod
157
+ yield* isPodFound.pipe(
158
+ Effect.repeat({
159
+ until: (found) => !found,
160
+ schedule: Schedule.spaced("3 seconds")
161
+ }),
162
+ Effect.orDie
163
+ )
164
+ }))
165
+
166
+ let opod = Option.none<Pod>()
167
+ while (Option.isNone(opod) || !opod.value.isReady) {
168
+ if (Option.isNone(opod)) {
169
+ yield* createPod
170
+ }
171
+ yield* Effect.sleep("3 seconds")
172
+ opod = yield* readPod
173
+ }
174
+ return opod.value.status
175
+ }, Effect.withSpan("K8sHttpClient.createPod"))
176
+ })
177
+
178
+ /**
179
+ * @since 1.0.0
180
+ * @category Schemas
181
+ */
182
+ export class PodStatus extends Schema.Class<PodStatus>("@effect/cluster/K8sHttpClient/PodStatus")({
183
+ phase: Schema.String,
184
+ conditions: Schema.Array(Schema.Struct({
185
+ type: Schema.String,
186
+ status: Schema.String,
187
+ lastTransitionTime: Schema.String
188
+ })),
189
+ podIP: Schema.String,
190
+ hostIP: Schema.String
191
+ }) {}
192
+
193
+ /**
194
+ * @since 1.0.0
195
+ * @category Schemas
196
+ */
197
+ export class Pod extends Schema.Class<Pod>("@effect/cluster/K8sHttpClient/Pod")({
198
+ status: PodStatus
199
+ }) {
200
+ get isReady(): boolean {
201
+ for (let i = 0; i < this.status.conditions.length; i++) {
202
+ const condition = this.status.conditions[i]
203
+ if (condition.type === "Ready") {
204
+ return condition.status === "True"
205
+ }
206
+ }
207
+ return false
208
+ }
209
+
210
+ get isReadyOrInitializing(): boolean {
211
+ let initializedAt: string | undefined
212
+ let readyAt: string | undefined
213
+ for (let i = 0; i < this.status.conditions.length; i++) {
214
+ const condition = this.status.conditions[i]
215
+ switch (condition.type) {
216
+ case "Initialized": {
217
+ if (condition.status !== "True") {
218
+ return true
219
+ }
220
+ initializedAt = condition.lastTransitionTime
221
+ break
222
+ }
223
+ case "Ready": {
224
+ if (condition.status === "True") {
225
+ return true
226
+ }
227
+ readyAt = condition.lastTransitionTime
228
+ break
229
+ }
230
+ }
231
+ }
232
+ // if the pod is still booting up, consider it ready as it would have
233
+ // already registered itself with RunnerStorage by now
234
+ return initializedAt === readyAt
235
+ }
236
+ }
237
+
238
+ const PodList = Schema.Struct({
239
+ items: Schema.Array(Pod)
240
+ })
@@ -1,17 +1,12 @@
1
1
  /**
2
2
  * @since 1.0.0
3
3
  */
4
- import * as FileSystem from "@effect/platform/FileSystem"
5
- import * as HttpClient from "@effect/platform/HttpClient"
6
- import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
7
- import * as HttpClientResponse from "@effect/platform/HttpClientResponse"
8
4
  import * as Context from "effect/Context"
9
5
  import * as Effect from "effect/Effect"
10
- import { identity } from "effect/Function"
11
6
  import * as Layer from "effect/Layer"
12
7
  import * as Schedule from "effect/Schedule"
13
- import * as Schema from "effect/Schema"
14
8
  import type * as Scope from "effect/Scope"
9
+ import * as K8s from "./K8sHttpClient.js"
15
10
  import type { RunnerAddress } from "./RunnerAddress.js"
16
11
  import * as Runners from "./Runners.js"
17
12
 
@@ -87,87 +82,17 @@ export const makeK8s = Effect.fnUntraced(function*(options?: {
87
82
  readonly namespace?: string | undefined
88
83
  readonly labelSelector?: string | undefined
89
84
  }) {
90
- const fs = yield* FileSystem.FileSystem
91
- const token = yield* fs.readFileString("/var/run/secrets/kubernetes.io/serviceaccount/token").pipe(
92
- Effect.option
93
- )
94
- const client = (yield* HttpClient.HttpClient).pipe(
95
- HttpClient.filterStatusOk
96
- )
97
- const baseRequest = HttpClientRequest.get("https://kubernetes.default.svc/api").pipe(
98
- token._tag === "Some" ? HttpClientRequest.bearerToken(token.value.trim()) : identity
99
- )
100
- const getPods = baseRequest.pipe(
101
- HttpClientRequest.appendUrl(options?.namespace ? `/v1/namespaces/${options.namespace}/pods` : "/v1/pods"),
102
- HttpClientRequest.setUrlParam("fieldSelector", "status.phase=Running"),
103
- options?.labelSelector ? HttpClientRequest.setUrlParam("labelSelector", options.labelSelector) : identity
104
- )
105
- const allPods = yield* client.execute(getPods).pipe(
106
- Effect.flatMap(HttpClientResponse.schemaBodyJson(PodList)),
107
- Effect.map((list) => {
108
- const pods = new Map<string, Pod>()
109
- for (let i = 0; i < list.items.length; i++) {
110
- const pod = list.items[i]
111
- pods.set(pod.status.podIP, pod)
112
- }
113
- return pods
114
- }),
115
- Effect.tapErrorCause((cause) => Effect.logWarning("Failed to fetch pods from Kubernetes API", cause)),
116
- Effect.cachedWithTTL("10 seconds")
117
- )
85
+ const allPods = yield* K8s.makeGetPods(options)
118
86
 
119
87
  return RunnerHealth.of({
120
88
  isAlive: (address) =>
121
89
  allPods.pipe(
122
- Effect.map((pods) => pods.get(address.host)?.isReady ?? false),
90
+ Effect.map((pods) => pods.get(address.host)?.isReadyOrInitializing ?? false),
123
91
  Effect.catchAllCause(() => Effect.succeed(true))
124
92
  )
125
93
  })
126
94
  })
127
95
 
128
- class Pod extends Schema.Class<Pod>("effect/cluster/RunnerHealth/Pod")({
129
- status: Schema.Struct({
130
- phase: Schema.String,
131
- conditions: Schema.Array(Schema.Struct({
132
- type: Schema.String,
133
- status: Schema.String,
134
- lastTransitionTime: Schema.String
135
- })),
136
- podIP: Schema.String
137
- })
138
- }) {
139
- get isReady(): boolean {
140
- let initializedAt: string | undefined
141
- let readyAt: string | undefined
142
- for (let i = 0; i < this.status.conditions.length; i++) {
143
- const condition = this.status.conditions[i]
144
- switch (condition.type) {
145
- case "Initialized": {
146
- if (condition.status !== "True") {
147
- return true
148
- }
149
- initializedAt = condition.lastTransitionTime
150
- break
151
- }
152
- case "Ready": {
153
- if (condition.status === "True") {
154
- return true
155
- }
156
- readyAt = condition.lastTransitionTime
157
- break
158
- }
159
- }
160
- }
161
- // if the pod is still booting up, consider it ready as it would have
162
- // already registered itself with RunnerStorage by now
163
- return initializedAt === readyAt
164
- }
165
- }
166
-
167
- const PodList = Schema.Struct({
168
- items: Schema.Array(Pod)
169
- })
170
-
171
96
  /**
172
97
  * A layer which will check the Kubernetes API to see if a Runner is healthy.
173
98
  *
@@ -188,5 +113,5 @@ export const layerK8s = (
188
113
  ): Layer.Layer<
189
114
  RunnerHealth,
190
115
  never,
191
- HttpClient.HttpClient | FileSystem.FileSystem
116
+ K8s.K8sHttpClient
192
117
  > => Layer.effect(RunnerHealth, makeK8s(options))
package/src/index.ts CHANGED
@@ -73,6 +73,11 @@ export * as Envelope from "./Envelope.js"
73
73
  */
74
74
  export * as HttpRunner from "./HttpRunner.js"
75
75
 
76
+ /**
77
+ * @since 1.0.0
78
+ */
79
+ export * as K8sHttpClient from "./K8sHttpClient.js"
80
+
76
81
  /**
77
82
  * @since 1.0.0
78
83
  */