@namzu/sandbox 13.0.0 → 14.0.0

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 (67) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/README.md +151 -0
  3. package/dist/backends/firecracker/protocol.d.ts +22 -0
  4. package/dist/backends/firecracker/protocol.d.ts.map +1 -1
  5. package/dist/backends/firecracker/protocol.js.map +1 -1
  6. package/dist/backends/firecracker/transport.d.ts +104 -9
  7. package/dist/backends/firecracker/transport.d.ts.map +1 -1
  8. package/dist/backends/firecracker/transport.js +139 -13
  9. package/dist/backends/firecracker/transport.js.map +1 -1
  10. package/dist/backends/kubernetes/egress-policy.d.ts +219 -0
  11. package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -0
  12. package/dist/backends/kubernetes/egress-policy.js +314 -0
  13. package/dist/backends/kubernetes/egress-policy.js.map +1 -0
  14. package/dist/backends/kubernetes/index.d.ts +374 -0
  15. package/dist/backends/kubernetes/index.d.ts.map +1 -0
  16. package/dist/backends/kubernetes/index.js +671 -0
  17. package/dist/backends/kubernetes/index.js.map +1 -0
  18. package/dist/backends/kubernetes/k8s-client.d.ts +125 -0
  19. package/dist/backends/kubernetes/k8s-client.d.ts.map +1 -0
  20. package/dist/backends/kubernetes/k8s-client.js +246 -0
  21. package/dist/backends/kubernetes/k8s-client.js.map +1 -0
  22. package/dist/backends/kubernetes/lease.d.ts +119 -0
  23. package/dist/backends/kubernetes/lease.d.ts.map +1 -0
  24. package/dist/backends/kubernetes/lease.js +151 -0
  25. package/dist/backends/kubernetes/lease.js.map +1 -0
  26. package/dist/backends/kubernetes/objects.d.ts +282 -0
  27. package/dist/backends/kubernetes/objects.d.ts.map +1 -0
  28. package/dist/backends/kubernetes/objects.js +156 -0
  29. package/dist/backends/kubernetes/objects.js.map +1 -0
  30. package/dist/backends/kubernetes/privilege-probe.d.ts +136 -0
  31. package/dist/backends/kubernetes/privilege-probe.d.ts.map +1 -0
  32. package/dist/backends/kubernetes/privilege-probe.js +185 -0
  33. package/dist/backends/kubernetes/privilege-probe.js.map +1 -0
  34. package/dist/backends/kubernetes/sandbox.d.ts +123 -0
  35. package/dist/backends/kubernetes/sandbox.d.ts.map +1 -0
  36. package/dist/backends/kubernetes/sandbox.js +299 -0
  37. package/dist/backends/kubernetes/sandbox.js.map +1 -0
  38. package/dist/backends/kubernetes/transport.d.ts +122 -0
  39. package/dist/backends/kubernetes/transport.d.ts.map +1 -0
  40. package/dist/backends/kubernetes/transport.js +197 -0
  41. package/dist/backends/kubernetes/transport.js.map +1 -0
  42. package/dist/backends/kubernetes/workspace.d.ts +381 -0
  43. package/dist/backends/kubernetes/workspace.d.ts.map +1 -0
  44. package/dist/backends/kubernetes/workspace.js +1064 -0
  45. package/dist/backends/kubernetes/workspace.js.map +1 -0
  46. package/dist/index.d.ts +132 -2
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +102 -34
  49. package/dist/index.js.map +1 -1
  50. package/dist/testing/sandbox-conformance.d.ts +193 -0
  51. package/dist/testing/sandbox-conformance.d.ts.map +1 -0
  52. package/dist/testing/sandbox-conformance.js +465 -0
  53. package/dist/testing/sandbox-conformance.js.map +1 -0
  54. package/package.json +5 -4
  55. package/src/backends/firecracker/protocol.ts +27 -0
  56. package/src/backends/firecracker/transport.ts +199 -28
  57. package/src/backends/kubernetes/egress-policy.ts +437 -0
  58. package/src/backends/kubernetes/index.ts +1012 -0
  59. package/src/backends/kubernetes/k8s-client.ts +352 -0
  60. package/src/backends/kubernetes/lease.ts +198 -0
  61. package/src/backends/kubernetes/objects.ts +363 -0
  62. package/src/backends/kubernetes/privilege-probe.ts +261 -0
  63. package/src/backends/kubernetes/sandbox.ts +395 -0
  64. package/src/backends/kubernetes/transport.ts +286 -0
  65. package/src/backends/kubernetes/workspace.ts +1386 -0
  66. package/src/index.ts +257 -35
  67. package/src/testing/sandbox-conformance.ts +667 -0
@@ -0,0 +1,437 @@
1
+ /**
2
+ * Egress policy translation for the Kubernetes backend, and the
3
+ * verify-not-trust check that stops a misconfigured cluster from silently
4
+ * running unenforced.
5
+ *
6
+ * Mirrors two existing shapes rather than inventing a third:
7
+ *
8
+ * - `../firecracker/index.ts`'s `resolveNetworkPolicy` — a pure(ish) mapping
9
+ * from the shared {@link EgressPolicy} union to what one specific
10
+ * enforcement point can actually carry, with an exhaustive switch that
11
+ * refuses an unknown kind instead of defaulting to open.
12
+ * - `../docker/index.ts`'s `assertNetworkCarriesThePolicy` — the refusal
13
+ * discipline. That function does not trust a docker network's NAME to
14
+ * mean "internal"; it inspects the daemon's own `{{.Internal}}` flag. The
15
+ * same principle drives {@link verifyEgressPolicyApplied} here: this
16
+ * backend does not trust that an operator applied the right
17
+ * `NetworkPolicy` because a name matches — it GETs the live object and
18
+ * compares its shape.
19
+ *
20
+ * ## What core `NetworkPolicy` can and cannot express
21
+ *
22
+ * Core Kubernetes `NetworkPolicy` has exactly three ways to name a
23
+ * destination: `ipBlock` (CIDR), `podSelector` and `namespaceSelector`. There
24
+ * is no hostname or FQDN concept anywhere in the resource. So of the four
25
+ * {@link EgressPolicy} kinds:
26
+ *
27
+ * - `deny-all` and `allow-all` are fully expressible — neither needs a
28
+ * hostname.
29
+ * - `static` and `resolver` are hostname allowlists and are NOT expressible
30
+ * under core `NetworkPolicy` at all. {@link assertEgressPolicyIsEnforceable}
31
+ * throws a named error for both, rather than silently downgrading to an
32
+ * `HTTP_PROXY`/`HTTPS_PROXY` environment variable the way the container
33
+ * tier's open allowlist gap does (`#398`) — a workload that ignores a
34
+ * proxy variable bypasses it entirely, and repeating that mistake behind a
35
+ * Kubernetes-shaped manifest would not fix it.
36
+ *
37
+ * `KubernetesEgressConfig.engine: 'cilium'` is the one escape hatch, and it
38
+ * is opt-in: a cluster that actually runs Cilium can express a hostname
39
+ * allowlist as a `CiliumNetworkPolicy` with `toFQDNs`, so declaring the
40
+ * engine turns the refusal into an emission. No other engine is supported in
41
+ * this batch — Calico's `HostEndpoint`/`GlobalNetworkPolicy` and any other
42
+ * FQDN-capable CNI would need their own translation, added as their own
43
+ * `engine` value with their own resource shape, not folded into this one by
44
+ * guessing.
45
+ *
46
+ * ## Why a NetworkPolicy always allows the cluster's own DNS
47
+ *
48
+ * agent-sandbox's OWN managed `NetworkPolicy`
49
+ * (`SandboxTemplateSpec.networkPolicy` /
50
+ * `networkPolicyManagement: Managed`, the CRD's default) already leaves the
51
+ * cluster's own name service reachable — the upstream reference deployment
52
+ * (kubernetes-sigs/agent-sandbox `examples/kata-aks/sandboxtemplate.yaml`)
53
+ * ships exactly "ingress from the router only, egress limited to DNS (53)
54
+ * and TCP/443". {@link CLUSTER_DNS_EGRESS_RULE} matches that convention
55
+ * (CoreDNS/kube-dns lives in the `kube-system` namespace on every cluster
56
+ * this was checked against) rather than inventing a different one.
57
+ *
58
+ * This is not a case of one policy overriding another: the Kubernetes API
59
+ * server UNIONS every `NetworkPolicy` selecting a pod — traffic is allowed if
60
+ * ANY matching policy's rule allows it, never the intersection — so this
61
+ * rule does not need to duplicate the managed policy to avoid "fighting" it
62
+ * in the sense of narrowing anything. It exists so THIS backend's own
63
+ * translated policy is self-sufficient: on a cluster where an operator left
64
+ * `networkPolicyManagement: Unmanaged`, or copied the managed shape
65
+ * imperfectly, `deny-all` still means "the cluster's own name service,
66
+ * nothing else" instead of a pod that cannot even resolve
67
+ * `kubernetes.default`.
68
+ *
69
+ * ## Never a proxy environment variable
70
+ *
71
+ * No code path in this module emits `HTTP_PROXY`, `HTTPS_PROXY` or any other
72
+ * proxy variable, and none of the module's own string constants contain
73
+ * those substrings either — a dedicated test scans both. Proxy variables are
74
+ * advisory: a process that does not read them is not bounded by them. The
75
+ * boundary here is the `NetworkPolicy` object itself, enforced by the CNI in
76
+ * the kernel, not by the workload's cooperation.
77
+ */
78
+
79
+ import { isDeepStrictEqual } from 'node:util'
80
+ import type { EgressPolicy } from '../../index.js'
81
+ import { KubernetesAlreadyGoneError, type KubernetesClient } from './k8s-client.js'
82
+ import {
83
+ CILIUM_NETWORK_POLICY_API_GROUP,
84
+ CILIUM_NETWORK_POLICY_API_VERSION,
85
+ CORE_NETWORK_POLICY_API_GROUP,
86
+ CORE_NETWORK_POLICY_API_VERSION,
87
+ ciliumNetworkPolicyPath,
88
+ networkPolicyPath,
89
+ sandboxTemplateLabel,
90
+ } from './objects.js'
91
+
92
+ /**
93
+ * `'core'` (the default) is plain Kubernetes `NetworkPolicy` — every cluster
94
+ * has it, and it cannot express a hostname. `'cilium'` opts into emitting a
95
+ * `CiliumNetworkPolicy` for a hostname allowlist, and is meaningless unless
96
+ * the target cluster actually runs Cilium as its CNI.
97
+ */
98
+ export type KubernetesEgressEngine = 'core' | 'cilium'
99
+
100
+ /**
101
+ * The config-level egress hook on {@link KubernetesBackendConfig}.
102
+ *
103
+ * Deliberately CONFIG-level, not per-`create()`: the enforcement point is a
104
+ * `NetworkPolicy` attached to the `SandboxTemplate` (or, for `static`, to a
105
+ * dedicated `CiliumNetworkPolicy`), and neither can be rewritten per running
106
+ * sandbox — the same reason `SandboxBackendOptions.egress` is refused by
107
+ * `assertEnforceable` in `index.ts`. That refusal is unchanged by this file;
108
+ * it covers a caller trying to override egress PER SANDBOX, while this is
109
+ * the one egress policy the whole backend enforces.
110
+ */
111
+ export interface KubernetesEgressConfig {
112
+ readonly policy: EgressPolicy
113
+ /**
114
+ * Name of the `NetworkPolicy` (or `CiliumNetworkPolicy`, under the
115
+ * `'cilium'` engine) an operator applied. Defaults to
116
+ * {@link defaultEgressPolicyName}'s output for the configured
117
+ * `sandboxTemplateName`.
118
+ */
119
+ readonly networkPolicyName?: string
120
+ /** Default `'core'`. See the type doc. */
121
+ readonly engine?: KubernetesEgressEngine
122
+ }
123
+
124
+ /** Where a translated policy is targeted, and what its `podSelector` names. */
125
+ export interface EgressPolicyTarget {
126
+ readonly namespace: string
127
+ readonly name: string
128
+ /**
129
+ * The label VALUE the translated policy's `podSelector` /
130
+ * `endpointSelector` matches — see {@link sandboxTemplateLabel} for the
131
+ * key and why every Sandbox this backend produces, pooled or direct,
132
+ * carries it.
133
+ */
134
+ readonly sandboxTemplateName: string
135
+ }
136
+
137
+ /**
138
+ * A translated policy: which kind of Kubernetes object it is, where it
139
+ * lives, and the manifest an operator applies verbatim. `kind` is the
140
+ * ENFORCEMENT RESOURCE this translation actually produced — for `deny-all`
141
+ * and `allow-all` that is always `'NetworkPolicy'`, even when
142
+ * `KubernetesEgressConfig.engine` is `'cilium'`, because core
143
+ * `NetworkPolicy` already expresses both fully and Cilium enforces plain
144
+ * `NetworkPolicy` objects too. `engine` only changes the outcome for a
145
+ * hostname allowlist, which needs Cilium's CRD to exist at all.
146
+ */
147
+ export interface KubernetesTranslatedEgressPolicy {
148
+ readonly kind: 'NetworkPolicy' | 'CiliumNetworkPolicy'
149
+ readonly namespace: string
150
+ readonly name: string
151
+ readonly manifest: Readonly<Record<string, unknown>>
152
+ }
153
+
154
+ /** `${sandboxTemplateName}-egress`, the name {@link KubernetesEgressConfig.networkPolicyName} defaults to. */
155
+ export function defaultEgressPolicyName(sandboxTemplateName: string): string {
156
+ return `${sandboxTemplateName}-egress`
157
+ }
158
+
159
+ /**
160
+ * Named refusal for a hostname allowlist with no FQDN-capable engine
161
+ * declared. Thrown SYNCHRONOUSLY from `buildKubernetesBackend` — see
162
+ * {@link assertEgressPolicyIsEnforceable} — so a misconfiguration surfaces
163
+ * during host wiring, the same moment `assertRuntimeClassIsApplicable` and
164
+ * the readiness-bounds validation do, rather than on the first `create()`.
165
+ */
166
+ export class KubernetesUnenforceableEgressPolicyError extends Error {
167
+ override readonly name = 'KubernetesUnenforceableEgressPolicyError'
168
+
169
+ constructor(readonly policyKind: 'static' | 'resolver') {
170
+ super(
171
+ `The kubernetes sandbox backend cannot enforce an egress policy of kind '${policyKind}' as a NetworkPolicy: core Kubernetes NetworkPolicy has only ipBlock, podSelector and namespaceSelector — it has no hostname/FQDN concept at all. This cluster needs an FQDN-capable policy engine: set config.egress.engine to 'cilium' if the target cluster actually runs it (see docs/sdk/kubernetes-sandbox.md's egress section), or configure an egress policy of 'deny-all' or 'allow-all' instead. Refusing rather than silently downgrading to an HTTP_PROXY/HTTPS_PROXY environment variable an uncooperative process can ignore.`,
172
+ )
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Synchronous, no-I/O precondition: can `policy.kind` be enforced under
178
+ * `engine` at all. Deliberately decided from the KIND alone — a `resolver`
179
+ * policy's `resolve()` is never invoked here, both because calling it just to
180
+ * prove a refusal would be wasted work (and possibly a side effect the host
181
+ * did not expect yet) and because this has to stay callable synchronously
182
+ * from `buildKubernetesBackend`, which contacts nothing.
183
+ */
184
+ export function assertEgressPolicyIsEnforceable(
185
+ policy: EgressPolicy,
186
+ engine: KubernetesEgressEngine,
187
+ ): void {
188
+ if ((policy.kind === 'static' || policy.kind === 'resolver') && engine !== 'cilium') {
189
+ throw new KubernetesUnenforceableEgressPolicyError(policy.kind)
190
+ }
191
+ }
192
+
193
+ /**
194
+ * The cluster DNS egress rule every translated `NetworkPolicy` carries,
195
+ * including on `deny-all`. See the module doc's "Why a NetworkPolicy always
196
+ * allows the cluster's own DNS" section.
197
+ */
198
+ const CLUSTER_DNS_EGRESS_RULE = {
199
+ to: [{ namespaceSelector: { matchLabels: { 'kubernetes.io/metadata.name': 'kube-system' } } }],
200
+ ports: [
201
+ { protocol: 'UDP', port: 53 },
202
+ { protocol: 'TCP', port: 53 },
203
+ ],
204
+ } as const
205
+
206
+ /**
207
+ * Cilium requires DNS lookups to be explicitly allowed AND made visible to
208
+ * the agent before `toFQDNs` enforcement can match anything a name resolves
209
+ * to: without a preceding rule granting DNS and turning on visibility, the
210
+ * sandbox's own lookups for the allowed hosts are invisible to Cilium's
211
+ * FQDN-to-IP mapping and `toFQDNs` matches nothing, which would make a
212
+ * `static`/`resolver` policy fail closed for every host rather than only the
213
+ * disallowed ones.
214
+ *
215
+ * Shape verified against Cilium's own worked example — the `toFQDNs`
216
+ * `CiliumNetworkPolicy` at https://docs.cilium.io/en/stable/security/dns/
217
+ * (the "DNS Based" policy walkthrough) allows egress to `kube-dns` on port 53
218
+ * with `rules.dns: [{ matchPattern: "*" }]` alongside its own `toFQDNs` rule
219
+ * — this constant reproduces that DNS-allow rule verbatim, including the
220
+ * `k8s:` label-source prefix on both `toEndpoints` selector keys (an
221
+ * unprefixed key defaults to label source `any`, a superset that would still
222
+ * match, but `any` is not what the cited example uses).
223
+ */
224
+ const CILIUM_DNS_VISIBILITY_RULE = {
225
+ toEndpoints: [
226
+ {
227
+ matchLabels: {
228
+ 'k8s:io.kubernetes.pod.namespace': 'kube-system',
229
+ 'k8s:k8s-app': 'kube-dns',
230
+ },
231
+ },
232
+ ],
233
+ toPorts: [
234
+ {
235
+ ports: [{ port: '53', protocol: 'ANY' }],
236
+ rules: { dns: [{ matchPattern: '*' }] },
237
+ },
238
+ ],
239
+ } as const
240
+
241
+ function buildCoreNetworkPolicy(
242
+ target: EgressPolicyTarget,
243
+ egress: readonly Readonly<Record<string, unknown>>[],
244
+ ): KubernetesTranslatedEgressPolicy {
245
+ return {
246
+ kind: 'NetworkPolicy',
247
+ namespace: target.namespace,
248
+ name: target.name,
249
+ manifest: {
250
+ apiVersion: `${CORE_NETWORK_POLICY_API_GROUP}/${CORE_NETWORK_POLICY_API_VERSION}`,
251
+ kind: 'NetworkPolicy',
252
+ metadata: { name: target.name, namespace: target.namespace },
253
+ spec: {
254
+ podSelector: { matchLabels: sandboxTemplateLabel(target.sandboxTemplateName) },
255
+ policyTypes: ['Egress'],
256
+ egress,
257
+ },
258
+ },
259
+ }
260
+ }
261
+
262
+ function buildCiliumNetworkPolicy(
263
+ target: EgressPolicyTarget,
264
+ allowedHosts: readonly string[],
265
+ ): KubernetesTranslatedEgressPolicy {
266
+ return {
267
+ kind: 'CiliumNetworkPolicy',
268
+ namespace: target.namespace,
269
+ name: target.name,
270
+ manifest: {
271
+ apiVersion: `${CILIUM_NETWORK_POLICY_API_GROUP}/${CILIUM_NETWORK_POLICY_API_VERSION}`,
272
+ kind: 'CiliumNetworkPolicy',
273
+ metadata: { name: target.name, namespace: target.namespace },
274
+ spec: {
275
+ endpointSelector: { matchLabels: sandboxTemplateLabel(target.sandboxTemplateName) },
276
+ egress: [
277
+ CILIUM_DNS_VISIBILITY_RULE,
278
+ { toFQDNs: allowedHosts.map((host) => ({ matchName: host })) },
279
+ ],
280
+ },
281
+ },
282
+ }
283
+ }
284
+
285
+ /**
286
+ * The pure translation: an {@link EgressPolicy} plus the declared
287
+ * {@link KubernetesEgressEngine} in, the concrete manifest this backend can
288
+ * ask an operator to apply out. Async only because `resolver` carries an
289
+ * async `resolve()` — `deny-all`, `allow-all` and `static` never await
290
+ * anything.
291
+ *
292
+ * `resolver` is resolved ONCE here (at whatever call site first needs the
293
+ * manifest — `buildKubernetesBackend`'s first `create()`, in practice), not
294
+ * once per `create()` the way the docker and Firecracker backends re-resolve
295
+ * a PER-SANDBOX `resolver` policy on every call. That is a deliberate
296
+ * difference, not a shortcut: the enforcement point here is one
297
+ * `NetworkPolicy`/`CiliumNetworkPolicy` object shared by every sandbox this
298
+ * backend produces, so there is exactly one manifest to compare the resolved
299
+ * hosts against, and re-resolving on every `create()` would only produce a
300
+ * value nothing downstream re-applies to the cluster.
301
+ */
302
+ export async function translateEgressPolicy(
303
+ policy: EgressPolicy,
304
+ engine: KubernetesEgressEngine,
305
+ target: EgressPolicyTarget,
306
+ ): Promise<KubernetesTranslatedEgressPolicy> {
307
+ assertEgressPolicyIsEnforceable(policy, engine)
308
+
309
+ switch (policy.kind) {
310
+ case 'deny-all':
311
+ return buildCoreNetworkPolicy(target, [CLUSTER_DNS_EGRESS_RULE])
312
+ case 'allow-all':
313
+ // No `to`/`ports` on an egress rule matches every destination and
314
+ // every port. `CLUSTER_DNS_EGRESS_RULE` is a strict subset of this,
315
+ // so it is folded in rather than listed twice.
316
+ return buildCoreNetworkPolicy(target, [{}])
317
+ case 'static':
318
+ // `assertEgressPolicyIsEnforceable` already threw above unless
319
+ // `engine === 'cilium'`, so reaching here means it did not.
320
+ return buildCiliumNetworkPolicy(target, policy.allowedHosts)
321
+ case 'resolver': {
322
+ const allowedHosts = await policy.resolve()
323
+ return buildCiliumNetworkPolicy(target, allowedHosts)
324
+ }
325
+ default: {
326
+ const exhaustive: never = policy
327
+ throw new Error(
328
+ `kubernetes: unhandled egress policy kind ${JSON.stringify(exhaustive)}. Refusing rather than defaulting to unrestricted network access.`,
329
+ )
330
+ }
331
+ }
332
+ }
333
+
334
+ /**
335
+ * Raised by {@link verifyEgressPolicyApplied} when the named object does not
336
+ * exist at all. This backend never CREATES the policy itself — like the
337
+ * docker backend's network, the boundary is operator-applied so it gets
338
+ * reviewed outside the hot create path, by whoever has cluster-admin rather
339
+ * than by whatever created the ServiceAccount token this backend runs with.
340
+ */
341
+ export class KubernetesEgressPolicyNotAppliedError extends Error {
342
+ override readonly name = 'KubernetesEgressPolicyNotAppliedError'
343
+
344
+ constructor(
345
+ readonly resourceKind: 'NetworkPolicy' | 'CiliumNetworkPolicy',
346
+ readonly path: string,
347
+ ) {
348
+ super(
349
+ `kubernetes: config.egress is set but no ${resourceKind} exists at ${path}. This backend never creates the egress policy itself — apply the manifest it computed (see docs/sdk/kubernetes-sandbox.md's egress section) before creating a sandbox with an egress policy configured. Refusing rather than creating a sandbox with no enforced network boundary.`,
350
+ )
351
+ }
352
+ }
353
+
354
+ /** Raised when the applied object exists but does not match the translation. */
355
+ export class KubernetesEgressPolicyMismatchError extends Error {
356
+ override readonly name = 'KubernetesEgressPolicyMismatchError'
357
+
358
+ constructor(
359
+ readonly resourceKind: 'NetworkPolicy' | 'CiliumNetworkPolicy',
360
+ readonly path: string,
361
+ readonly reason: string,
362
+ ) {
363
+ super(
364
+ `kubernetes: the ${resourceKind} at ${path} does not match the egress policy config.egress computed: ${reason}. Re-apply the manifest this backend translated rather than hand-editing the cluster object — a policy that has drifted from config silently changes what a sandbox can reach without config saying so.`,
365
+ )
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Verify-not-trust: GET the object {@link translateEgressPolicy} named and
371
+ * assert its shape actually matches, rather than trusting that an object
372
+ * with the right name does what config says. Mirrors
373
+ * `../docker/index.ts`'s `assertNetworkCarriesThePolicy`, which inspects the
374
+ * daemon's own `{{.Internal}}` flag instead of trusting a network's name.
375
+ *
376
+ * Checks exactly three things, each named separately in a mismatch so an
377
+ * operator sees which one to fix:
378
+ * - the selector (`podSelector` for `NetworkPolicy`, `endpointSelector` for
379
+ * `CiliumNetworkPolicy`) carries the expected template label:
380
+ * - `NetworkPolicy` additionally declares `policyTypes` including
381
+ * `'Egress'` — a `NetworkPolicy` with an `egress` array but no `'Egress'`
382
+ * in `policyTypes` enforces nothing on egress at all;
383
+ * - the `egress` rule array matches the translation exactly.
384
+ *
385
+ * Never mutates and never creates — a 404/410 is refused, not repaired.
386
+ */
387
+ export async function verifyEgressPolicyApplied(
388
+ client: KubernetesClient,
389
+ translated: KubernetesTranslatedEgressPolicy,
390
+ signal?: AbortSignal,
391
+ ): Promise<void> {
392
+ const path =
393
+ translated.kind === 'CiliumNetworkPolicy'
394
+ ? ciliumNetworkPolicyPath(translated.namespace, translated.name)
395
+ : networkPolicyPath(translated.namespace, translated.name)
396
+
397
+ let resource: { readonly spec?: Readonly<Record<string, unknown>> } | undefined
398
+ try {
399
+ resource = await client.request('GET', path, undefined, signal)
400
+ } catch (err) {
401
+ if (err instanceof KubernetesAlreadyGoneError) {
402
+ throw new KubernetesEgressPolicyNotAppliedError(translated.kind, path)
403
+ }
404
+ throw err
405
+ }
406
+
407
+ const expectedSpec = (translated.manifest as { readonly spec: Record<string, unknown> }).spec
408
+ const actualSpec = resource?.spec ?? {}
409
+ const selectorKey = translated.kind === 'CiliumNetworkPolicy' ? 'endpointSelector' : 'podSelector'
410
+
411
+ if (!isDeepStrictEqual(actualSpec[selectorKey], expectedSpec[selectorKey])) {
412
+ throw new KubernetesEgressPolicyMismatchError(
413
+ translated.kind,
414
+ path,
415
+ `spec.${selectorKey} is ${JSON.stringify(actualSpec[selectorKey])}, expected ${JSON.stringify(expectedSpec[selectorKey])} — the label every Sandbox this backend creates carries`,
416
+ )
417
+ }
418
+
419
+ if (translated.kind === 'NetworkPolicy') {
420
+ const policyTypes = actualSpec.policyTypes
421
+ if (!Array.isArray(policyTypes) || !policyTypes.includes('Egress')) {
422
+ throw new KubernetesEgressPolicyMismatchError(
423
+ translated.kind,
424
+ path,
425
+ `spec.policyTypes is ${JSON.stringify(policyTypes)}, expected an array containing 'Egress' — without it, spec.egress enforces nothing`,
426
+ )
427
+ }
428
+ }
429
+
430
+ if (!isDeepStrictEqual(actualSpec.egress, expectedSpec.egress)) {
431
+ throw new KubernetesEgressPolicyMismatchError(
432
+ translated.kind,
433
+ path,
434
+ `spec.egress is ${JSON.stringify(actualSpec.egress)}, expected ${JSON.stringify(expectedSpec.egress)}`,
435
+ )
436
+ }
437
+ }