@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.
- package/K8sHttpClient/package.json +6 -0
- package/dist/cjs/EntityResource.js +34 -19
- package/dist/cjs/EntityResource.js.map +1 -1
- package/dist/cjs/K8sHttpClient.js +164 -0
- package/dist/cjs/K8sHttpClient.js.map +1 -0
- package/dist/cjs/RunnerHealth.js +3 -63
- package/dist/cjs/RunnerHealth.js.map +1 -1
- package/dist/cjs/index.js +3 -1
- package/dist/dts/EntityResource.d.ts +23 -11
- package/dist/dts/EntityResource.d.ts.map +1 -1
- package/dist/dts/K8sHttpClient.d.ts +91 -0
- package/dist/dts/K8sHttpClient.d.ts.map +1 -0
- package/dist/dts/RunnerHealth.d.ts +3 -4
- package/dist/dts/RunnerHealth.d.ts.map +1 -1
- package/dist/dts/index.d.ts +4 -0
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/esm/EntityResource.js +32 -18
- package/dist/esm/EntityResource.js.map +1 -1
- package/dist/esm/K8sHttpClient.js +153 -0
- package/dist/esm/K8sHttpClient.js.map +1 -0
- package/dist/esm/RunnerHealth.js +3 -63
- package/dist/esm/RunnerHealth.js.map +1 -1
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -1
- package/package.json +12 -1
- package/src/EntityResource.ts +53 -32
- package/src/K8sHttpClient.ts +240 -0
- package/src/RunnerHealth.ts +4 -79
- package/src/index.ts +5 -0
|
@@ -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
|
+
})
|
package/src/RunnerHealth.ts
CHANGED
|
@@ -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
|
|
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)?.
|
|
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
|
-
|
|
116
|
+
K8s.K8sHttpClient
|
|
192
117
|
> => Layer.effect(RunnerHealth, makeK8s(options))
|