src/config.bend

bend-kube-runtime@0.2.0.0 · 375 lines · 55 definitions

1import Base
2import bend-kube-runtime@0.2.0.0/src/json.bend as J
3import bend-kube-runtime@0.2.0.0/src/utf8.bend as U
4import bend-kube-runtime@0.2.0.0/src/base64.bend as B
5
6# Config
7# ======
8#
9# Where the API server is and how to prove who we are (kube-client's
10# Config). Pure: a kubeconfig or the in-cluster environment becomes a Config
11# whose secrets are either inline or still files to read; the IO side
12# (client.bend) reads them, and re-reads token files on every connection,
13# so rotated service-account tokens are picked up (client-go's
14# CachedFileTokenSource).
15
16# A secret's source: inline text, a file to read, a credential plugin to
17# run (client-go's exec ExecCredential: its status.token), or nothing.
18type Src is Data:
19  Inline{text: String}
20  FromFile{path: String}
21  FromExec{cmd: String, args: List<&2, String>}
22  NoSrc{}
23
24# pool: idle keep-alive connections to reuse (client.bend), when the
25# program made one (Config.with_pool); None opens one per request.
26type Config is Data:
27  Config{host: String, port: U32, base: String, ca: Src, cert: Src, key: Src, token: Src,
28    namespace: String, pool: Maybe<&2, Chan(Socket)>}
29
30def Config.host(c: Config) -> String:
31  match c:
32    case Config{h, p, b, ca, cert, key, tok, ns, pl}:
33      h
34
35def Config.namespace(c: Config) -> String:
36  match c:
37    case Config{h, p, b, ca, cert, key, tok, ns, pl}:
38      ns
39
40# Server URLs
41# -----------
42
43type Url is Data:
44  Url{host: String, port: U32, base: String}
45
46def Url.drop_scheme.go(s: String, tls: Bool) -> String:
47  match tls:
48    case True{}:
49      String.drop(s, 8n)
50    case False{}:
51      s
52
53def Url.drop_scheme(+s: String) -> String:
54  Url.drop_scheme.go(s, String.starts_with(s, "https://"))
55
56# Splits "authority/path" at the first '/'.
57def Url.split_path(s: String, acc: String) -> String & String:
58  match s:
59    case SNil{}:
60      (String.reverse(acc), "")
61    case SCon{'/', t}:
62      (String.reverse(acc), SCon{'/', t})
63    case SCon{c, t}:
64      Url.split_path(t, SCon{c, acc})
65
66def Url.port(p: String, +dflt: U32) -> U32:
67  Maybe.default(&2, U32, U32.read(p), dflt)
68
69def Url.host_port.v6(parts: List<&2, String>, +dflt: U32) -> String & U32:
70  match parts:
71    case Con{h, Con{p, rest}}:
72      (h, Url.port(String.drop(p, 1n), dflt))
73    case Con{h, Nil{}}:
74      (h, dflt)
75    case Nil{}:
76      ("", dflt)
77
78def Url.host_port.v4(parts: List<&2, String>, +dflt: U32) -> String & U32:
79  match parts:
80    case Con{h, Con{p, rest}}:
81      (h, Url.port(p, dflt))
82    case Con{h, Nil{}}:
83      (h, dflt)
84    case Nil{}:
85      ("", dflt)
86
87# "[::1]:6443" or "host:6443" or "host": the host and the port (dflt when
88# none is given).
89def Url.host_port(a: String, +dflt: U32) -> String & U32:
90  match a:
91    case SCon{'[', t}:
92      Url.host_port.v6(String.split(t, ']'), dflt)
93    case +x:
94      Url.host_port.v4(String.split(x, ':'), dflt)
95
96def Url.trim_slash.go(s: String) -> String:
97  match s:
98    case SCon{'/', t}:
99      t
100    case x:
101      x
102
103def Url.trim_slash(s: String) -> String:
104  String.reverse(Url.trim_slash.go(String.reverse(s)))
105
106def Url.parse.fin(hp: String & U32, base: String) -> Url:
107  match hp:
108    case (h, p):
109      Url{h, p, base}
110
111def Url.parse.go(ap: String & String) -> Url:
112  match ap:
113    case (a, p):
114      Url.parse.fin(Url.host_port(a, 443), Url.trim_slash(p))
115
116def Url.parse(s: String) -> Url:
117  Url.parse.go(Url.split_path(Url.drop_scheme(s), ""))
118
119def Kube.named.if(+x: J.Json, +field: String, rest: J.Json, hit: Bool) -> J.Json:
120  match hit:
121    case True{}:
122      J.Json.get(x, field)
123    case False{}:
124      rest
125
126# Kubeconfig
127# ----------
128
129# The entry of a named list ("clusters", "users", "contexts") with this
130# name, as its inner object (cluster, user or context).
131def Kube.named(xs: List<&2, J.Json>, +name: String, +field: String) -> J.Json:
132  match xs:
133    case Nil{}:
134      J.JNull{}
135    case Con{+x, t}:
136      Kube.named.if(x, field, Kube.named(t, name, field),
137        String.eq(J.Json.str_or(J.Json.get(x, "name"), ""), name))
138
139def Kube.src.b64(bs: Maybe<&2, List<&2, U32>>) -> Src:
140  match bs:
141    case Some{b}:
142      Inline{U.Utf8.decode_all(b)}
143    case None{}:
144      NoSrc{}
145
146def Kube.src.go(data: J.Json, path: J.Json) -> Src:
147  match data path:
148    case J.JStr{d} _:
149      Kube.src.b64(B.B64.decode(d))
150    case _ J.JStr{p}:
151      FromFile{p}
152    case _ _:
153      NoSrc{}
154
155# A secret from "<name>-data" (base64, inline) or "<name>" (a path).
156def Kube.src(+o: J.Json, +name: String) -> Src:
157  Kube.src.go(J.Json.get(o, name ++ "-data"), J.Json.get(o, name))
158
159def Kube.strs(xs: List<&2, J.Json>) -> List<&2, String>:
160  match xs:
161    case Nil{}:
162      Nil{}
163    case Con{J.JStr{x}, t}:
164      x <> Kube.strs(t)
165    case Con{x, t}:
166      Kube.strs(t)
167
168# A plugin runs under `env`, which sets the plugin's env entries (and
169# KUBERNETES_EXEC_INFO, as client-go does) on top of our environment.
170def Kube.token.exec(c: J.Json, env: List<&2, String>, args: List<&2, String>) -> Src:
171  match c:
172    case J.JStr{cmd}:
173      FromExec{"env", List.append(&2, String, env,
174        ("KUBERNETES_EXEC_INFO={\"apiVersion\":\"client.authentication.k8s.io/v1\",\"kind\":\"ExecCredential\",\"spec\":{\"interactive\":false}}")
175          <> cmd <> args)}
176    case _:
177      NoSrc{}
178
179# An exec plugin's env entries ({name, value}) as "name=value".
180def Kube.envs(xs: List<&2, J.Json>) -> List<&2, String>:
181  match xs:
182    case Nil{}:
183      Nil{}
184    case Con{+x, t}:
185      (J.Json.str_or(J.Json.get(x, "name"), "") ++ "=" ++ J.Json.str_or(J.Json.get(x, "value"), ""))
186        <> Kube.envs(t)
187
188def Kube.token.go(t: J.Json, f: J.Json, +e: J.Json) -> Src:
189  match t f:
190    case J.JStr{s} _:
191      Inline{s}
192    case _ J.JStr{p}:
193      FromFile{p}
194    case _ _:
195      Kube.token.exec(J.Json.get(e, "command"), Kube.envs(J.Json.items(J.Json.get(e, "env"))),
196        Kube.strs(J.Json.items(J.Json.get(e, "args"))))
197
198def Kube.token(+u: J.Json) -> Src:
199  Kube.token.go(J.Json.get(u, "token"), J.Json.get(u, "tokenFile"), J.Json.get(u, "exec"))
200
201def Kube.config.pick.go(ctx: String, cur: String, empty: Bool) -> String:
202  match empty:
203    case True{}:
204      cur
205    case False{}:
206      ctx
207
208def Kube.config.pick(+ctx: String, cur: String) -> String:
209  Kube.config.pick.go(ctx, cur, String.is_empty(ctx))
210
211def Kube.config.fin(u: Url, ca: Src, cert: Src, key: Src, tok: Src, ns: String) ->
212  Result<&2, &2, String, Config>:
213  match u:
214    case Url{h, p, b}:
215      Done{Config{h, p, b, ca, cert, key, tok, ns, None{}}}
216
217def Kube.config.server(s: J.Json, ca: Src, cert: Src, key: Src, tok: Src, ns: String) ->
218  Result<&2, &2, String, Config>:
219  match s:
220    case J.JStr{url}:
221      Kube.config.fin(Url.parse(url), ca, cert, key, tok, ns)
222    case _:
223      Fail{"kubeconfig: the cluster has no server"}
224
225def Kube.config.build(+cl: J.Json, +u: J.Json, ns: String, name: String, missing: Bool) ->
226  Result<&2, &2, String, Config>:
227  match missing:
228    case True{}:
229      Fail{"kubeconfig: no context named " ++ name}
230    case False{}:
231      Kube.config.server(J.Json.get(cl, "server"), Kube.src(cl, "certificate-authority"),
232        Kube.src(u, "client-certificate"), Kube.src(u, "client-key"), Kube.token(u), ns)
233
234def Kube.config.with(+kc: J.Json, name: String, +c: J.Json) -> Result<&2, &2, String, Config>:
235  Kube.config.build(
236    Kube.named(J.Json.items(J.Json.get(kc, "clusters")),
237      J.Json.str_or(J.Json.get(c, "cluster"), ""), "cluster"),
238    Kube.named(J.Json.items(J.Json.get(kc, "users")),
239      J.Json.str_or(J.Json.get(c, "user"), ""), "user"),
240    J.Json.str_or(J.Json.get(c, "namespace"), "default"), name, J.Json.is_null(c))
241
242def Kube.config.ctx(+kc: J.Json, +name: String) -> Result<&2, &2, String, Config>:
243  Kube.config.with(kc, name, Kube.named(J.Json.items(J.Json.get(kc, "contexts")), name, "context"))
244
245# The Config a kubeconfig (as JSON) names for a context ("" for its
246# current-context), or why it cannot.
247def Kube.config(+kc: J.Json, ctx: String) -> Result<&2, &2, String, Config>:
248  Kube.config.ctx(kc, Kube.config.pick(ctx, J.Json.str_or(J.Json.get(kc, "current-context"), "")))
249
250# In-cluster
251# ----------
252
253def InCluster.dir() -> String:
254  "/var/run/secrets/kubernetes.io/serviceaccount"
255
256# The pod's service account, from KUBERNETES_SERVICE_HOST and _PORT
257# (client-go's InClusterConfig). An IPv6 host needs no brackets here.
258def InCluster.config(host: String, port: String, ns: String) -> Config:
259  Config{host, Url.port(port, 443), "", FromFile{InCluster.dir() ++ "/ca.crt"}, NoSrc{}, NoSrc{},
260    FromFile{InCluster.dir() ++ "/token"}, String.trim(ns), None{}}
261
262# Merging and paths
263# -----------------
264
265def Kube.names(xs: List<&2, J.Json>) -> List<&2, String>:
266  match xs:
267    case Nil{}:
268      Nil{}
269    case Con{+x, t}:
270      J.Json.str_or(J.Json.get(x, "name"), "") <> Kube.names(t)
271
272def Kube.merge.new.put(x: J.Json, rest: List<&2, J.Json>, dup: Bool) -> List<&2, J.Json>:
273  match dup:
274    case True{}:
275      rest
276    case False{}:
277      x <> rest
278
279def Kube.merge.new(b: List<&2, J.Json>, +seen: List<&2, String>) -> List<&2, J.Json>:
280  match b:
281    case Nil{}:
282      Nil{}
283    case Con{+x, t}:
284      Kube.merge.new.put(x, Kube.merge.new(t, seen),
285        List.contains(~String, ~String.eq, seen, J.Json.str_or(J.Json.get(x, "name"), "")))
286
287def Kube.merge.list(+a: List<&2, J.Json>, b: List<&2, J.Json>) -> List<&2, J.Json>:
288  List.append(&2, J.Json, a, Kube.merge.new(b, Kube.names(a)))
289
290def Kube.merge.field(+a: J.Json, +b: J.Json, +f: String) -> J.Json:
291  J.Json.arr(Kube.merge.list(J.Json.items(J.Json.get(a, f)), J.Json.items(J.Json.get(b, f))))
292
293def Kube.merge.cur(a: String, b: String) -> String:
294  Kube.config.pick(a, b)
295
296# Two kubeconfigs as client-go merges a KUBECONFIG list: an entry named in
297# the first wins over the same name in the second, and current-context is
298# the first one set.
299def Kube.merge(+a: J.Json, +b: J.Json) -> J.Json:
300  J.Json.obj([J.KV{"clusters", Kube.merge.field(a, b, "clusters")},
301    J.KV{"contexts", Kube.merge.field(a, b, "contexts")},
302    J.KV{"users", Kube.merge.field(a, b, "users")},
303    J.KV{"current-context", J.JStr{Kube.merge.cur(J.Json.str_or(J.Json.get(a, "current-context"), ""),
304      J.Json.str_or(J.Json.get(b, "current-context"), ""))}}])
305
306def Kube.rebase.str(dir: String, s: String, abs: Bool) -> String:
307  match abs:
308    case True{}:
309      s
310    case False{}:
311      dir ++ "/" ++ s
312
313# A relative path in a kubeconfig is relative to the kubeconfig's directory.
314def Kube.rebase.path(+dir: String, p: J.Json) -> J.Json:
315  match p:
316    case J.JStr{+s}:
317      J.JStr{Kube.rebase.str(dir, s, String.starts_with(s, "/"))}
318    case x:
319      x
320
321def Kube.rebase.key(+o: J.Json, +dir: String, +k: String, missing: Bool) -> J.Json:
322  match missing:
323    case True{}:
324      o
325    case False{}:
326      J.Json.set(o, k, Kube.rebase.path(dir, J.Json.get(o, k)))
327
328def Kube.rebase.keys(ks: List<&2, String>, +o: J.Json, +dir: String) -> J.Json:
329  match ks:
330    case Nil{}:
331      o
332    case Con{+k, t}:
333      Kube.rebase.keys(t, Kube.rebase.key(o, dir, k, J.Json.is_null(J.Json.get(o, k))), dir)
334
335def Kube.rebase.items(xs: List<&2, J.Json>, +dir: String, +inner: String, +ks: List<&2, String>) ->
336  List<&2, J.Json>:
337  match xs:
338    case Nil{}:
339      Nil{}
340    case Con{+x, t}:
341      J.Json.set(x, inner, Kube.rebase.keys(ks, J.Json.get(x, inner), dir))
342        <> Kube.rebase.items(t, dir, inner, ks)
343
344# The kubeconfig with every file path made absolute against dir.
345def Kube.rebase(+kc: J.Json, +dir: String) -> J.Json:
346  J.Json.set(J.Json.set(kc, "clusters",
347    J.Json.arr(Kube.rebase.items(J.Json.items(J.Json.get(kc, "clusters")), dir, "cluster",
348      ["certificate-authority"]))),
349    "users", J.Json.arr(Kube.rebase.items(J.Json.items(J.Json.get(kc, "users")), dir, "user",
350      ["client-certificate", "client-key", "tokenFile"])))
351
352def Kube.dir.fin(d: String, bare: Bool) -> String:
353  match bare:
354    case True{}:
355      "."
356    case False{}:
357      d
358
359def Kube.dir.go(+parts: List<&2, String>) -> String:
360  Kube.dir.fin(String.join(List.take(&2, String, parts, Nat.sub(List.length(&2, String, parts), 1n)), "/"),
361    Nat.is_le(List.length(&2, String, parts), 1n))
362
363# The directory of a path ("." when it has none).
364def Kube.dir(+p: String) -> String:
365  Kube.dir.go(String.split(p, '/'))
366
367def Config.with_pool(c: Config, pool: Chan(Socket)) -> Config:
368  match c:
369    case Config{h, p, b, ca, cert, key, tok, ns, pl}:
370      Config{h, p, b, ca, cert, key, tok, ns, Some{pool}}
371
372def Config.pool(c: Config) -> Maybe<&2, Chan(Socket)>:
373  match c:
374    case Config{h, p, b, ca, cert, key, tok, ns, pl}:
375      pl