1 import Base 2 import ./json.bend as J 3 import ./utf8.bend as U 4 import ./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. 18 type 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. 26 type 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 30 def Config.host(c: Config) -> String: 31 match c: 32 case Config{h, p, b, ca, cert, key, tok, ns, pl}: 33 h 34 35 def 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 43 type Url is Data: 44 Url{host: String, port: U32, base: String} 45 46 def 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 53 def 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 '/'. 57 def 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 66 def Url.port(p: String, +dflt: U32) -> U32: 67 Maybe.default(&2, U32, U32.read(p), dflt) 68 69 def 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 78 def 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). 89 def 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 96 def Url.trim_slash.go(s: String) -> String: 97 match s: 98 case SCon{'/', t}: 99 t 100 case x: 101 x 102 103 def Url.trim_slash(s: String) -> String: 104 String.reverse(Url.trim_slash.go(String.reverse(s))) 105 106 def Url.parse.fin(hp: String & U32, base: String) -> Url: 107 match hp: 108 case (h, p): 109 Url{h, p, base} 110 111 def 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 116 def Url.parse(s: String) -> Url: 117 Url.parse.go(Url.split_path(Url.drop_scheme(s), "")) 118 119 def 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). 131 def 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 139 def 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 146 def 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 "-data" (base64, inline) or "" (a path). 156 def Kube.src(+o: J.Json, +name: String) -> Src: 157 Kube.src.go(J.Json.get(o, name ++ "-data"), J.Json.get(o, name)) 158 159 def 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. 170 def 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". 180 def 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 188 def 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 198 def 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 201 def 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 208 def Kube.config.pick(+ctx: String, cur: String) -> String: 209 Kube.config.pick.go(ctx, cur, String.is_empty(ctx)) 210 211 def 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 217 def 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 225 def 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 234 def 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 242 def 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. 247 def 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 253 def 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. 258 def 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 265 def 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 272 def 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 279 def 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 287 def 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 290 def 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 293 def 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. 299 def 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 306 def 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. 314 def 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 321 def 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 328 def 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 335 def 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. 345 def 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 352 def Kube.dir.fin(d: String, bare: Bool) -> String: 353 match bare: 354 case True{}: 355 "." 356 case False{}: 357 d 358 359 def 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). 364 def Kube.dir(+p: String) -> String: 365 Kube.dir.go(String.split(p, '/')) 366 367 def 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 372 def 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