@jr2/orchestrator 0.2.1 → 0.4.1

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.
Files changed (75) hide show
  1. package/bin/server.ts +7 -4
  2. package/console/canvas.ts +27 -14
  3. package/console/components/app.ts +10 -1
  4. package/console/components/fleet.ts +39 -6
  5. package/console/components/machine-pane.ts +89 -2
  6. package/console/components/nav.ts +1 -1
  7. package/console/components/schema-form.ts +12 -4
  8. package/console/json-field.ts +23 -0
  9. package/console/main.ts +110 -39
  10. package/console/store.ts +221 -17
  11. package/console/style.css +90 -0
  12. package/package.json +4 -5
  13. package/src/actor.ts +507 -54
  14. package/src/agent.ts +2 -2
  15. package/src/ambient.ts +2 -0
  16. package/src/assembly-checks.ts +165 -0
  17. package/src/assembly-tar.ts +494 -0
  18. package/src/assembly.ts +721 -0
  19. package/src/chart.ts +2050 -0
  20. package/src/charts.ts +720 -0
  21. package/src/config.ts +233 -22
  22. package/src/content-hash.ts +105 -0
  23. package/src/custodian.lua +313 -0
  24. package/src/custodian.ts +951 -0
  25. package/src/customize.ts +216 -25
  26. package/src/effect-checks.ts +557 -0
  27. package/src/effect.ts +905 -0
  28. package/src/effector-wire.ts +525 -0
  29. package/src/fingerprint.ts +4 -3
  30. package/src/gate.ts +8 -0
  31. package/src/git-clone.ts +65 -0
  32. package/src/git-push.ts +336 -0
  33. package/src/harness-client.ts +247 -25
  34. package/src/held-secrets.ts +643 -0
  35. package/src/holds.ts +156 -0
  36. package/src/http-effect.ts +981 -0
  37. package/src/http.ts +514 -119
  38. package/src/images.ts +58 -31
  39. package/src/index.ts +49 -4
  40. package/src/instance.ts +445 -59
  41. package/src/kube-client.ts +333 -0
  42. package/src/machine-doc.ts +88 -6
  43. package/src/model-keys.ts +92 -0
  44. package/src/mount-digest.ts +53 -0
  45. package/src/names.ts +89 -7
  46. package/src/notices.ts +88 -0
  47. package/src/open-body.ts +1444 -0
  48. package/src/open-effect.ts +144 -0
  49. package/src/open.ts +4 -4
  50. package/src/parts.ts +530 -33
  51. package/src/party.ts +468 -0
  52. package/src/pick.ts +309 -0
  53. package/src/pool.ts +1 -1
  54. package/src/registration.ts +301 -17
  55. package/src/repo-fetch.ts +61 -86
  56. package/src/repos.ts +63 -74
  57. package/src/rest.ts +133 -0
  58. package/src/run-host.ts +1841 -105
  59. package/src/sandbox-effect.ts +891 -0
  60. package/src/sandbox-effector.ts +111 -0
  61. package/src/sandbox-kube.ts +1719 -0
  62. package/src/sandbox-watch.ts +260 -0
  63. package/src/secret-placement.ts +494 -0
  64. package/src/server.ts +285 -89
  65. package/src/setup.ts +589 -70
  66. package/src/size.ts +342 -0
  67. package/src/snapshot-store.ts +64 -13
  68. package/src/stub-harness.ts +5 -0
  69. package/src/sync-client.ts +54 -0
  70. package/src/timers.ts +178 -0
  71. package/src/tokens.ts +44 -12
  72. package/src/vocabulary.ts +66 -1
  73. package/src/wire.ts +156 -2
  74. package/src/workspace.ts +529 -194
  75. package/src/sandbox-kubectl.ts +0 -1144
@@ -0,0 +1,313 @@
1
+ -- The Custodian's decisions (ADR-0059), run by Envoy's Lua filter on every listener it owns.
2
+ --
3
+ -- Envoy carries the bytes: the CONNECT listener, TLS on both sides, the upstream pools, streaming.
4
+ -- This script makes every choice about a credential, and nothing else. It ships once per Instance
5
+ -- in the `jr2-held` ConfigMap (`program.lua`); each pod's own `custodian.lua` is a generated `HELD`
6
+ -- table (names, headers, paths, bound targets — never a value) that ends by loading this file.
7
+ -- Envoy loads that once per Lua state at start, so the top-level code below runs before any
8
+ -- listener serves, with `HELD` already set: a value that is missing or cannot ride a header stops
9
+ -- the start-up with a line that names the secret, never the value.
10
+ --
11
+ -- Four roles, one per route family, read from the route's metadata:
12
+ --
13
+ -- control the Harness's Menu and the ask, plain HTTP on loopback, toward the Orchestrator
14
+ -- egress the CONNECT listener: dial guard, then tunnel or hand to an intercept listener
15
+ -- intercept one bound host, TLS already ended: swap the Stand-in, strip the rest
16
+ -- health the kubelet's probe
17
+
18
+ local VALUES = "/etc/jr2/custodian/values/"
19
+
20
+ -- The value of one held secret, read once. One trailing line end is taken off, because
21
+ -- `kubectl create secret --from-file` writes one; anything else a header cannot carry stops us.
22
+ local function load(name)
23
+ local f = io.open(VALUES .. name, "rb")
24
+ if f == nil then
25
+ error("jr2.custodian refuse secret=" .. name .. " reason=missing-value")
26
+ end
27
+ local v = f:read("*a")
28
+ f:close()
29
+ v = v:gsub("\r?\n$", "", 1)
30
+ if v == "" then
31
+ error("jr2.custodian refuse secret=" .. name .. " reason=empty-value")
32
+ end
33
+ if v:find("[^\32-\126]") then
34
+ error("jr2.custodian refuse secret=" .. name .. " reason=value-not-printable-ascii")
35
+ end
36
+ return v
37
+ end
38
+
39
+ local STAND_IN = {}
40
+ local VALUE = {}
41
+ for _, s in ipairs(HELD.secrets) do
42
+ STAND_IN[s.name] = "jr2-held-" .. s.name
43
+ VALUE[s.name] = load(s.name)
44
+ end
45
+
46
+ -- The headers every bound target treats as a credential (`CREDENTIAL_HEADERS`, held-secrets.ts).
47
+ local CREDENTIAL = {}
48
+ for _, h in ipairs(HELD.credential) do CREDENTIAL[h] = true end
49
+
50
+ -- Everything a target's requests may carry a credential in: the fixed set, plus every header a
51
+ -- secret bound to it names.
52
+ local function credentialHeaders(bound)
53
+ local set = {}
54
+ for h in pairs(CREDENTIAL) do set[h] = true end
55
+ for _, s in ipairs(bound) do
56
+ for _, h in ipairs(s.headers) do set[h] = true end
57
+ end
58
+ return set
59
+ end
60
+
61
+ local function tag(handle, key, value)
62
+ handle:streamInfo():dynamicMetadata():set("jr2", key, value)
63
+ end
64
+
65
+ local function refuse(handle, status, reason, body)
66
+ tag(handle, "action", "refuse")
67
+ tag(handle, "reason", reason)
68
+ tag(handle, "log", true)
69
+ handle:respond({ [":status"] = tostring(status), ["content-type"] = "text/plain" }, "jr2 custodian: " .. body .. "\n")
70
+ end
71
+
72
+ -- The swap, in the two forms a client writes a key in: the Stand-in alone (`x-api-key`), or one
73
+ -- auth scheme then the Stand-in (`Authorization: Bearer`). Nothing else is ever rewritten.
74
+ local function swapped(value, standIn, secret)
75
+ if value == standIn then return secret end
76
+ local scheme, rest = value:match("^([A-Za-z][A-Za-z0-9._~+/-]*) (.+)$")
77
+ if scheme ~= nil and rest == standIn then return scheme .. " " .. secret end
78
+ return nil
79
+ end
80
+
81
+ -- One request toward a target whose secrets are `bound`: every credential header is either the
82
+ -- product of a swap or gone, and a request that swapped nothing never leaves.
83
+ local function hold(handle, headers, bound, target, path)
84
+ local creds = credentialHeaders(bound)
85
+ for h in pairs(creds) do
86
+ if headers:getNumValues(h) > 1 then
87
+ return refuse(handle, 400, "credential", target .. " got header " .. h .. " more than once")
88
+ end
89
+ end
90
+ local done = {}
91
+ local names = {}
92
+ for _, s in ipairs(bound) do
93
+ for _, h in ipairs(s.headers) do
94
+ local v = headers:get(h)
95
+ if v ~= nil and not done[h] then
96
+ local out = swapped(v, STAND_IN[s.name], VALUE[s.name])
97
+ if out ~= nil then
98
+ if s.paths ~= nil then
99
+ local allowed = false
100
+ for _, p in ipairs(s.paths) do
101
+ if path == p or path:sub(1, #p) == p and (p:sub(-1) == "/" or path:sub(#p + 1, #p + 1) == "/") then
102
+ allowed = true
103
+ end
104
+ end
105
+ if not allowed then
106
+ return refuse(handle, 403, "path", s.name .. " may not be sent to this path of " .. target)
107
+ end
108
+ end
109
+ headers:replace(h, out)
110
+ done[h] = true
111
+ names[#names + 1] = s.name
112
+ end
113
+ end
114
+ end
115
+ end
116
+ for h in pairs(creds) do
117
+ if not done[h] then headers:remove(h) end
118
+ end
119
+ if #names == 0 then
120
+ local wanted, where = {}, {}
121
+ for _, s in ipairs(bound) do
122
+ wanted[#wanted + 1] = s.name
123
+ for _, h in ipairs(s.headers) do where[#where + 1] = h end
124
+ end
125
+ return refuse(handle, 403, "stand-in", target .. " needs the stand-in of " .. table.concat(wanted, ", ") ..
126
+ " in " .. table.concat(where, ", "))
127
+ end
128
+ tag(handle, "action", "intercept")
129
+ tag(handle, "secret", table.concat(names, ","))
130
+ tag(handle, "log", true)
131
+ end
132
+
133
+ -- A CONNECT target, one spelling: lowercase, no trailing dot on the name.
134
+ local function normal(authority)
135
+ local a = authority:lower()
136
+ local host, port = a:match("^(%[[^%]]+%]):(%d+)$")
137
+ if host == nil then host, port = a:match("^([^:]+):(%d+)$") end
138
+ if host == nil then return nil end
139
+ host = host:gsub("%.$", "")
140
+ return host, port
141
+ end
142
+
143
+ local function ipv4(host)
144
+ local a, b, c, d = host:match("^(%d+)%.(%d+)%.(%d+)%.(%d+)$")
145
+ if a == nil then return nil end
146
+ a, b, c, d = tonumber(a), tonumber(b), tonumber(c), tonumber(d)
147
+ if a > 255 or b > 255 or c > 255 or d > 255 then return nil end
148
+ return a, b, c, d
149
+ end
150
+
151
+ -- An IPv6 literal as eight numbers, or nil.
152
+ local function ipv6(host)
153
+ local s = host:match("^%[(.+)%]$")
154
+ if s == nil or not s:find(":") then return nil end
155
+ local v4 = s:match(":(%d+%.%d+%.%d+%.%d+)$")
156
+ if v4 ~= nil then
157
+ local a, b, c, d = ipv4(v4)
158
+ if a == nil then return nil end
159
+ s = s:sub(1, #s - #v4) .. string.format("%x:%x", a * 256 + b, c * 256 + d)
160
+ end
161
+ local head, rest = s:match("^(.-)::(.*)$")
162
+ local function groups(part)
163
+ local out = {}
164
+ if part == "" then return out end
165
+ for g in (part .. ":"):gmatch("([^:]*):") do
166
+ if not g:match("^%x%x?%x?%x?$") then return nil end
167
+ out[#out + 1] = tonumber(g, 16)
168
+ end
169
+ return out
170
+ end
171
+ local left, right
172
+ if head == nil then
173
+ left, right = groups(s), {}
174
+ if left == nil or #left ~= 8 then return nil end
175
+ else
176
+ left, right = groups(head), groups(rest)
177
+ if left == nil or right == nil or #left + #right > 7 then return nil end
178
+ end
179
+ local out = {}
180
+ for _, g in ipairs(left) do out[#out + 1] = g end
181
+ for _ = 1, 8 - #left - #right do out[#out + 1] = 0 end
182
+ for _, g in ipairs(right) do out[#out + 1] = g end
183
+ return out
184
+ end
185
+
186
+ -- The dial guard's first half: a tunnel never reaches loopback, link-local (the cloud metadata
187
+ -- address among it), or an unspecified address — nor an IPv4-mapped spelling of one. By NAME it
188
+ -- knows only `localhost`. The second half is Envoy's: an RBAC filter judges the address the
189
+ -- dynamic forward proxy resolved a name to, and `envoy_on_response` logs its refusal (ADR-0059).
190
+ local function guarded(host)
191
+ if host == "localhost" or host:sub(-10) == ".localhost" then return true end
192
+ local a, b = ipv4(host)
193
+ if a ~= nil then
194
+ return a == 127 or a == 0 or (a == 169 and b == 254)
195
+ end
196
+ local g = ipv6(host)
197
+ if g ~= nil then
198
+ local zero = true
199
+ for i = 1, 7 do if g[i] ~= 0 then zero = false end end
200
+ if zero and (g[8] == 0 or g[8] == 1) then return true end
201
+ if g[1] >= 0xfe80 and g[1] <= 0xfebf then return true end
202
+ if g[1] == 0xfd00 and g[2] == 0xec2 and g[3] == 0 and g[4] == 0 and g[5] == 0 and g[6] == 0 and g[7] == 0
203
+ and g[8] == 0x254 then return true end
204
+ local mapped = g[1] == 0 and g[2] == 0 and g[3] == 0 and g[4] == 0 and g[5] == 0 and g[6] == 0xffff
205
+ if mapped then
206
+ local x, y = math.floor(g[7] / 256), g[7] % 256
207
+ return x == 127 or x == 0 or (x == 169 and y == 254)
208
+ end
209
+ end
210
+ return false
211
+ end
212
+
213
+ local function egress(handle, headers)
214
+ if headers:get(":method") ~= "CONNECT" then
215
+ return refuse(handle, 405, "method", "this listener speaks CONNECT alone")
216
+ end
217
+ local authority = headers:get(":authority") or ""
218
+ local host, port = normal(authority)
219
+ if host == nil then
220
+ return refuse(handle, 400, "target", "a CONNECT names host:port")
221
+ end
222
+ local target = host .. ":" .. port
223
+ if target ~= authority then
224
+ headers:replace(":authority", target)
225
+ handle:clearRouteCache()
226
+ end
227
+ if HELD.targets[target] ~= nil then
228
+ -- Handed to the intercept listener, which logs each request it carries.
229
+ return
230
+ end
231
+ if guarded(host) then
232
+ return refuse(handle, 403, "guard", target .. " is loopback, link-local or unspecified")
233
+ end
234
+ tag(handle, "action", "tunnel")
235
+ tag(handle, "log", true)
236
+ end
237
+
238
+ -- What a bound host is sent. TRACE is not: a server that answers it echoes the request, the
239
+ -- swapped value among it, back to the Agent. Neither is a WebDAV or other extension method.
240
+ local METHODS = { GET = true, HEAD = true, POST = true, PUT = true, PATCH = true, DELETE = true, OPTIONS = true }
241
+
242
+ local function intercept(handle, headers, target)
243
+ if not METHODS[headers:get(":method")] then
244
+ return refuse(handle, 405, "method", target .. " takes GET, HEAD, POST, PUT, PATCH, DELETE or OPTIONS here")
245
+ end
246
+ if headers:get("upgrade") ~= nil then
247
+ return refuse(handle, 501, "upgrade", target .. " takes no protocol upgrade here")
248
+ end
249
+ local host = (headers:get(":authority") or ""):lower()
250
+ local want = target
251
+ if target:sub(-4) == ":443" then
252
+ if host == target:sub(1, -5) then host = target end
253
+ end
254
+ if host ~= want then
255
+ return refuse(handle, 421, "host", "Host does not name " .. target)
256
+ end
257
+ local path = (headers:get(":path") or "/"):match("^[^?]*")
258
+ if path:lower():find("%%2[ef]") then
259
+ return refuse(handle, 400, "path", "an encoded / or . in the path")
260
+ end
261
+ for _, h in ipairs({ "te", "trailer", "keep-alive", "proxy-connection" }) do headers:remove(h) end
262
+ return hold(handle, headers, HELD.targets[target], target, path)
263
+ end
264
+
265
+ local function control(handle, headers, route)
266
+ if route == "fetch" then
267
+ local sandbox = os.getenv("JR2_SANDBOX")
268
+ if sandbox == nil or sandbox == "" then
269
+ tag(handle, "action", "refuse")
270
+ tag(handle, "reason", "no-sandbox")
271
+ tag(handle, "log", true)
272
+ return handle:respond({ [":status"] = "404", ["content-type"] = "application/json" },
273
+ '{"error":"this pod serves no Sandbox, so it mounts no Repo to fetch (ADR-0053)"}')
274
+ end
275
+ headers:replace(":path", "/sandboxes/" .. sandbox .. "/fetch")
276
+ end
277
+ return hold(handle, headers, HELD.control, "orchestrator", headers:get(":path"))
278
+ end
279
+
280
+ local READY = false
281
+
282
+ -- A refusal by resolved address comes from the RBAC filter after this one: the one 403 a request
283
+ -- this script let through as a tunnel can get. It is logged as a refusal by spelling is.
284
+ function envoy_on_response(handle)
285
+ if handle:metadata():get("role") ~= "egress" then return end
286
+ if handle:headers():get(":status") ~= "403" then return end
287
+ local jr2 = handle:streamInfo():dynamicMetadata():get("jr2")
288
+ if jr2 == nil or jr2.action ~= "tunnel" then return end
289
+ tag(handle, "action", "refuse")
290
+ tag(handle, "reason", "guard")
291
+ end
292
+
293
+ function envoy_on_request(handle)
294
+ local meta = handle:metadata()
295
+ local role = meta:get("role")
296
+ local headers = handle:headers()
297
+ if role == "egress" then return egress(handle, headers) end
298
+ if role == "intercept" then return intercept(handle, headers, meta:get("target")) end
299
+ if role == "control" then return control(handle, headers, meta:get("route")) end
300
+ if role == "health" then
301
+ if not READY then
302
+ READY = true
303
+ local names = {}
304
+ for _, s in ipairs(HELD.secrets) do names[#names + 1] = s.name end
305
+ io.stdout:write("jr2.custodian ready secrets=" .. table.concat(names, ",") .. " hosts=" .. HELD.hosts .. "\n")
306
+ io.stdout:flush()
307
+ end
308
+ return handle:respond({ [":status"] = "200", ["content-type"] = "text/plain" }, "ok\n")
309
+ end
310
+ if role == "deny" then
311
+ return refuse(handle, 404, "route", "no such route")
312
+ end
313
+ end