@namzu/sandbox 25.0.0 → 27.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.
- package/CHANGELOG.md +57 -0
- package/README.md +20 -7
- package/dist/backends/firecracker/protocol.d.ts +46 -0
- package/dist/backends/firecracker/protocol.d.ts.map +1 -1
- package/dist/backends/firecracker/protocol.js +31 -0
- package/dist/backends/firecracker/protocol.js.map +1 -1
- package/dist/backends/firecracker/transport.d.ts +15 -1
- package/dist/backends/firecracker/transport.d.ts.map +1 -1
- package/dist/backends/firecracker/transport.js +16 -0
- package/dist/backends/firecracker/transport.js.map +1 -1
- package/dist/backends/kubernetes/allocation.d.ts +115 -0
- package/dist/backends/kubernetes/allocation.d.ts.map +1 -0
- package/dist/backends/kubernetes/allocation.js +130 -0
- package/dist/backends/kubernetes/allocation.js.map +1 -0
- package/dist/backends/kubernetes/egress-policy.d.ts +111 -35
- package/dist/backends/kubernetes/egress-policy.d.ts.map +1 -1
- package/dist/backends/kubernetes/egress-policy.js +81 -41
- package/dist/backends/kubernetes/egress-policy.js.map +1 -1
- package/dist/backends/kubernetes/identity.d.ts +129 -0
- package/dist/backends/kubernetes/identity.d.ts.map +1 -1
- package/dist/backends/kubernetes/identity.js +150 -0
- package/dist/backends/kubernetes/identity.js.map +1 -1
- package/dist/backends/kubernetes/index.d.ts +23 -1
- package/dist/backends/kubernetes/index.d.ts.map +1 -1
- package/dist/backends/kubernetes/index.js +22 -3
- package/dist/backends/kubernetes/index.js.map +1 -1
- package/dist/backends/kubernetes/objects.d.ts +76 -0
- package/dist/backends/kubernetes/objects.d.ts.map +1 -1
- package/dist/backends/kubernetes/objects.js +61 -0
- package/dist/backends/kubernetes/objects.js.map +1 -1
- package/dist/backends/kubernetes/per-sandbox-fence.d.ts +194 -0
- package/dist/backends/kubernetes/per-sandbox-fence.d.ts.map +1 -0
- package/dist/backends/kubernetes/per-sandbox-fence.js +522 -0
- package/dist/backends/kubernetes/per-sandbox-fence.js.map +1 -0
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts +45 -3
- package/dist/backends/kubernetes/per-sandbox-policy.d.ts.map +1 -1
- package/dist/backends/kubernetes/per-sandbox-policy.js +98 -8
- package/dist/backends/kubernetes/per-sandbox-policy.js.map +1 -1
- package/dist/backends/kubernetes/sandbox.d.ts +4 -9
- package/dist/backends/kubernetes/sandbox.d.ts.map +1 -1
- package/dist/backends/kubernetes/sandbox.js +4 -9
- package/dist/backends/kubernetes/sandbox.js.map +1 -1
- package/dist/backends/kubernetes/transport.d.ts +108 -1
- package/dist/backends/kubernetes/transport.d.ts.map +1 -1
- package/dist/backends/kubernetes/transport.js +150 -6
- package/dist/backends/kubernetes/transport.js.map +1 -1
- package/dist/backends/kubernetes/workspace-allocate.d.ts +206 -0
- package/dist/backends/kubernetes/workspace-allocate.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace-allocate.js +332 -0
- package/dist/backends/kubernetes/workspace-allocate.js.map +1 -0
- package/dist/backends/kubernetes/workspace-observe.d.ts +187 -0
- package/dist/backends/kubernetes/workspace-observe.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace-observe.js +343 -0
- package/dist/backends/kubernetes/workspace-observe.js.map +1 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.d.ts +94 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.d.ts.map +1 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.js +119 -0
- package/dist/backends/kubernetes/workspace-per-sandbox.js.map +1 -0
- package/dist/backends/kubernetes/workspace.d.ts +139 -5
- package/dist/backends/kubernetes/workspace.d.ts.map +1 -1
- package/dist/backends/kubernetes/workspace.js +434 -36
- package/dist/backends/kubernetes/workspace.js.map +1 -1
- package/dist/index.d.ts +83 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +100 -6
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/backends/firecracker/protocol.ts +47 -0
- package/src/backends/firecracker/transport.ts +31 -0
- package/src/backends/kubernetes/allocation.ts +199 -0
- package/src/backends/kubernetes/egress-policy.ts +165 -42
- package/src/backends/kubernetes/identity.ts +210 -0
- package/src/backends/kubernetes/index.ts +61 -2
- package/src/backends/kubernetes/objects.ts +98 -0
- package/src/backends/kubernetes/per-sandbox-fence.ts +800 -0
- package/src/backends/kubernetes/per-sandbox-policy.ts +152 -17
- package/src/backends/kubernetes/sandbox.ts +4 -9
- package/src/backends/kubernetes/transport.ts +203 -4
- package/src/backends/kubernetes/workspace-allocate.ts +607 -0
- package/src/backends/kubernetes/workspace-observe.ts +590 -0
- package/src/backends/kubernetes/workspace-per-sandbox.ts +179 -0
- package/src/backends/kubernetes/workspace.ts +655 -34
- package/src/index.ts +193 -3
|
@@ -470,6 +470,51 @@ export interface KubernetesPerSandboxEgressConfig {
|
|
|
470
470
|
* leave `tlsServerNames` off for a domain list.
|
|
471
471
|
*/
|
|
472
472
|
readonly narrowing?: KubernetesCiliumEgressNarrowing
|
|
473
|
+
/**
|
|
474
|
+
* The identity the operator's fence bounds, spelled as the API server
|
|
475
|
+
* spells a user: `system:serviceaccount:<namespace>:<name>` for a
|
|
476
|
+
* ServiceAccount. When set, the fence check requires the policy's
|
|
477
|
+
* `matchConditions` to name exactly this identity
|
|
478
|
+
* (`request.userInfo.username == '<identity>'`), and refuses before any
|
|
479
|
+
* write when they name another one or none.
|
|
480
|
+
*
|
|
481
|
+
* Unset (the default), the identity is not checked: the shipped fence
|
|
482
|
+
* names `system:serviceaccount:namzu-sandboxes:namzu-sandbox-host`, and a
|
|
483
|
+
* host that authenticates as something else would otherwise hold the write
|
|
484
|
+
* verbs with a fence that never matches it — which reads exactly like an
|
|
485
|
+
* enforced one. Set it whenever you know who the host is.
|
|
486
|
+
*/
|
|
487
|
+
readonly hostIdentity?: string
|
|
488
|
+
/**
|
|
489
|
+
* The operator-pinned set of hosts and ports a host may grant. Names the
|
|
490
|
+
* `ConfigMap` that holds it; see
|
|
491
|
+
* `k8s/manifests/validatingadmissionpolicy-cilium-permitted-hosts.yaml`
|
|
492
|
+
* for the companion policy that enforces it at admission and the shape of
|
|
493
|
+
* the ConfigMap.
|
|
494
|
+
*
|
|
495
|
+
* Set, three things change and nothing else does:
|
|
496
|
+
*
|
|
497
|
+
* - the fence check additionally proves the companion policy and its
|
|
498
|
+
* binding (the policy name with `-hosts` appended, and its binding),
|
|
499
|
+
* including that the binding's `paramRef` is THIS ConfigMap and denies
|
|
500
|
+
* when it is missing;
|
|
501
|
+
* - every `setNetworkPolicy` call with a non-empty list reads the
|
|
502
|
+
* ConfigMap first and refuses locally, with a named error and nothing
|
|
503
|
+
* written, a host or a port outside it. A narrowing that leaves a
|
|
504
|
+
* host's ports unrestricted is a port outside any list;
|
|
505
|
+
* - a missing or malformed ConfigMap refuses every such call.
|
|
506
|
+
*
|
|
507
|
+
* Unset (the default), none of that runs and behaviour is what it was.
|
|
508
|
+
*/
|
|
509
|
+
readonly permittedHostsParam?: KubernetesPermittedHostsParam
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** Where the operator-pinned permitted hosts and ports live. */
|
|
513
|
+
export interface KubernetesPermittedHostsParam {
|
|
514
|
+
/** The `ConfigMap`'s name. */
|
|
515
|
+
readonly name: string
|
|
516
|
+
/** Its namespace. */
|
|
517
|
+
readonly namespace: string
|
|
473
518
|
}
|
|
474
519
|
|
|
475
520
|
/**
|
|
@@ -654,7 +699,14 @@ export class KubernetesPerSandboxEgressConfigError extends Error {
|
|
|
654
699
|
override readonly name = 'KubernetesPerSandboxEgressConfigError'
|
|
655
700
|
|
|
656
701
|
constructor(
|
|
657
|
-
readonly field:
|
|
702
|
+
readonly field:
|
|
703
|
+
| 'engine'
|
|
704
|
+
| 'admissionPolicyName'
|
|
705
|
+
| 'admissionPolicyBindingName'
|
|
706
|
+
| 'labelKey'
|
|
707
|
+
| 'hostIdentity'
|
|
708
|
+
| 'permittedHostsParam.name'
|
|
709
|
+
| 'permittedHostsParam.namespace',
|
|
658
710
|
readonly value: string,
|
|
659
711
|
reason: string,
|
|
660
712
|
) {
|
|
@@ -755,60 +807,53 @@ export function assertPerSandboxEgressIsUsable(egress: KubernetesEgressConfig |
|
|
|
755
807
|
if (perSandbox.narrowing !== undefined) {
|
|
756
808
|
assertCiliumNarrowingIsUsable(perSandbox.narrowing, 'perSandbox.narrowing')
|
|
757
809
|
}
|
|
810
|
+
const { hostIdentity, permittedHostsParam } = perSandbox
|
|
811
|
+
if (hostIdentity !== undefined && (hostIdentity === '' || hostIdentity.trim() !== hostIdentity)) {
|
|
812
|
+
throw new KubernetesPerSandboxEgressConfigError(
|
|
813
|
+
'hostIdentity',
|
|
814
|
+
hostIdentity,
|
|
815
|
+
'is not a user name (an empty or space-padded identity matches nothing, so the fence check would refuse every write)',
|
|
816
|
+
)
|
|
817
|
+
}
|
|
818
|
+
if (permittedHostsParam !== undefined) {
|
|
819
|
+
for (const field of ['name', 'namespace'] as const) {
|
|
820
|
+
const value = (permittedHostsParam as Partial<Record<'name' | 'namespace', string>>)[field]
|
|
821
|
+
if (typeof value !== 'string' || value === '' || value.trim() !== value) {
|
|
822
|
+
throw new KubernetesPerSandboxEgressConfigError(
|
|
823
|
+
`permittedHostsParam.${field}`,
|
|
824
|
+
String(value),
|
|
825
|
+
'is not an object name (the parameter ConfigMap is named by both fields, and an empty or space-padded one names nothing, so every write would be refused)',
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
758
830
|
perSandboxEgressLabelKey(egress)
|
|
759
831
|
}
|
|
760
832
|
|
|
761
833
|
/**
|
|
762
|
-
*
|
|
763
|
-
*
|
|
834
|
+
* Formerly the refusal of `config.egress.perSandbox` on
|
|
835
|
+
* `createKubernetesWorkspace` and `allocateKubernetesWorkspace`, which could
|
|
836
|
+
* not carry the capability. They carry it now: the workspace's pod template
|
|
837
|
+
* gets the per-sandbox selector label, the `Sandbox` owns the policies, and
|
|
838
|
+
* the handle serves `setNetworkPolicy`.
|
|
764
839
|
*
|
|
765
|
-
*
|
|
766
|
-
*
|
|
767
|
-
*
|
|
768
|
-
*
|
|
769
|
-
* A workspace's create path does none of that — it composes no per-sandbox
|
|
770
|
-
* pod label and tracks no owner uid for one — so on that path the option
|
|
771
|
-
* would be accepted and mean nothing at all: exactly the "declared and
|
|
772
|
-
* silently unused" configuration this module refuses everywhere else. The
|
|
773
|
-
* alternative, omitting the method and saying nothing, is how a host comes to
|
|
774
|
-
* believe it narrowed a workspace's egress.
|
|
840
|
+
* Nothing throws this any more. It stays exported so an existing
|
|
841
|
+
* `catch (err) { if (err instanceof KubernetesWorkspacePerSandboxEgressConfigError) … }`
|
|
842
|
+
* keeps compiling and simply never matches; it will be removed in a later
|
|
843
|
+
* major.
|
|
775
844
|
*
|
|
776
|
-
*
|
|
777
|
-
* about a `perSandbox` value this backend cannot honour ANYWHERE (an engine
|
|
778
|
-
* with no hostname concept, an unnamed fence). This one is about a `perSandbox`
|
|
779
|
-
* value it honours perfectly well on the other entry point, so a caller
|
|
780
|
-
* catching the sibling for a typo'd value does not also catch a correct
|
|
781
|
-
* configuration aimed at the wrong path.
|
|
845
|
+
* @deprecated No workspace entry point refuses `egress.perSandbox` any more.
|
|
782
846
|
*/
|
|
783
847
|
export class KubernetesWorkspacePerSandboxEgressConfigError extends Error {
|
|
784
848
|
override readonly name = 'KubernetesWorkspacePerSandboxEgressConfigError'
|
|
785
849
|
|
|
786
850
|
constructor() {
|
|
787
851
|
super(
|
|
788
|
-
|
|
852
|
+
'kubernetes: config.egress.perSandbox is set on a workspace entry point. This error is no longer thrown: workspaces serve per-sandbox egress.',
|
|
789
853
|
)
|
|
790
854
|
}
|
|
791
855
|
}
|
|
792
856
|
|
|
793
|
-
/**
|
|
794
|
-
* Refuse `config.egress.perSandbox` on the entry point that cannot carry it —
|
|
795
|
-
* `createKubernetesWorkspace`. See
|
|
796
|
-
* {@link KubernetesWorkspacePerSandboxEgressConfigError}.
|
|
797
|
-
*
|
|
798
|
-
* Deliberately not {@link assertPerSandboxEgressIsUsable}, which validates the
|
|
799
|
-
* same config for the path that DOES honour it: a workspace has nothing to
|
|
800
|
-
* validate the option for, whatever its `engine` or `admissionPolicyName` say,
|
|
801
|
-
* because it serves no `setNetworkPolicy` at all. Calling that validator here
|
|
802
|
-
* instead would be worse than saying nothing — it would report a
|
|
803
|
-
* `perSandbox` this deployment can use as if it were in use.
|
|
804
|
-
*/
|
|
805
|
-
export function assertWorkspaceCarriesNoPerSandboxEgress(
|
|
806
|
-
egress: KubernetesEgressConfig | undefined,
|
|
807
|
-
): void {
|
|
808
|
-
if (egress?.perSandbox === undefined) return
|
|
809
|
-
throw new KubernetesWorkspacePerSandboxEgressConfigError()
|
|
810
|
-
}
|
|
811
|
-
|
|
812
857
|
/**
|
|
813
858
|
* The ONE composer for a sandbox pod's `additionalPodMetadata.labels`.
|
|
814
859
|
*
|
|
@@ -1686,7 +1731,7 @@ export function assertHostsFitNarrowing(
|
|
|
1686
1731
|
* `undefined` means no port restriction: the host's `toFQDNs` rule carries
|
|
1687
1732
|
* no `toPorts` at all.
|
|
1688
1733
|
*/
|
|
1689
|
-
function narrowedHostPorts(
|
|
1734
|
+
export function narrowedHostPorts(
|
|
1690
1735
|
host: string,
|
|
1691
1736
|
narrowing: KubernetesCiliumEgressNarrowing,
|
|
1692
1737
|
): readonly number[] | undefined {
|
|
@@ -2482,6 +2527,18 @@ export interface EgressAllowance {
|
|
|
2482
2527
|
readonly kind: 'NetworkPolicy' | 'CiliumNetworkPolicy'
|
|
2483
2528
|
readonly name: string
|
|
2484
2529
|
}
|
|
2530
|
+
/**
|
|
2531
|
+
* Further objects whose `spec` was compared to its translation by
|
|
2532
|
+
* {@link verifyEgressPolicyApplied}, and which the union rule therefore
|
|
2533
|
+
* does not judge rule by rule either. Set only by
|
|
2534
|
+
* {@link unionEgressAllowance}: the post-write check of a per-sandbox
|
|
2535
|
+
* policy is a question about TWO named objects, the operator's baseline
|
|
2536
|
+
* and the policy just written.
|
|
2537
|
+
*/
|
|
2538
|
+
readonly alsoNamed?: readonly {
|
|
2539
|
+
readonly kind: 'NetworkPolicy' | 'CiliumNetworkPolicy'
|
|
2540
|
+
readonly name: string
|
|
2541
|
+
}[]
|
|
2485
2542
|
}
|
|
2486
2543
|
|
|
2487
2544
|
function readPortRanges(ports: unknown, defaultProtocol: string | undefined): PortSet {
|
|
@@ -2652,6 +2709,55 @@ export function egressAllowance(translated: KubernetesTranslatedEgressPolicy): E
|
|
|
2652
2709
|
}
|
|
2653
2710
|
}
|
|
2654
2711
|
|
|
2712
|
+
/**
|
|
2713
|
+
* What the pod may reach when TWO translated policies both select it: the
|
|
2714
|
+
* operator's baseline and one per-sandbox policy this backend wrote.
|
|
2715
|
+
*
|
|
2716
|
+
* Kubernetes unions every policy that selects a pod, so the boundary a
|
|
2717
|
+
* per-sandbox write is supposed to produce is exactly baseline PLUS the
|
|
2718
|
+
* written policy, and a third policy allowing more than that is what the
|
|
2719
|
+
* post-write check refuses. Each field is the union of the two sides':
|
|
2720
|
+
*
|
|
2721
|
+
* - destinations and `toFQDNs` names concatenate;
|
|
2722
|
+
* - `permitsEverything` is either side's, and `permitsNothing` is both
|
|
2723
|
+
* sides' — a `no-network` baseline permits nothing, and a baseline that
|
|
2724
|
+
* permits nothing plus a per-sandbox policy that permits one name permits
|
|
2725
|
+
* that name, not nothing;
|
|
2726
|
+
* - DNS lookups: a side that permits nothing contributes no names, a side
|
|
2727
|
+
* that does not narrow DNS contributes every name, and the union of name
|
|
2728
|
+
* lists is their concatenation. This is the one field where the naive
|
|
2729
|
+
* union is wrong: a `no-network` baseline reports `'all'` because a core
|
|
2730
|
+
* policy "cannot narrow DNS", and taking that at face value would let any
|
|
2731
|
+
* other policy open the resolver to every name beside a narrowed grant.
|
|
2732
|
+
*
|
|
2733
|
+
* Both objects are marked as named: each had its `spec` compared to its own
|
|
2734
|
+
* translation before this check runs, and neither's `.domain` entries (a
|
|
2735
|
+
* `matchName` plus a `matchPattern`) are expressible in an allowance.
|
|
2736
|
+
*/
|
|
2737
|
+
export function unionEgressAllowance(
|
|
2738
|
+
baseline: EgressAllowance,
|
|
2739
|
+
added: EgressAllowance,
|
|
2740
|
+
): EgressAllowance {
|
|
2741
|
+
const dnsOf = (side: EgressAllowance): 'all' | readonly string[] =>
|
|
2742
|
+
side.permitsNothing ? [] : side.dnsNarrowedTo
|
|
2743
|
+
const baselineDns = dnsOf(baseline)
|
|
2744
|
+
const addedDns = dnsOf(added)
|
|
2745
|
+
const dnsNarrowedTo: 'all' | readonly string[] =
|
|
2746
|
+
baselineDns === 'all' || addedDns === 'all'
|
|
2747
|
+
? 'all'
|
|
2748
|
+
: [...new Set([...baselineDns, ...addedDns])]
|
|
2749
|
+
return {
|
|
2750
|
+
destinations: [...baseline.destinations, ...added.destinations],
|
|
2751
|
+
fqdns: [...baseline.fqdns, ...added.fqdns],
|
|
2752
|
+
dnsNarrowedTo,
|
|
2753
|
+
permitsEverything: baseline.permitsEverything || added.permitsEverything,
|
|
2754
|
+
permitsNothing: baseline.permitsNothing && added.permitsNothing,
|
|
2755
|
+
policyKind: baseline.policyKind,
|
|
2756
|
+
namedObject: baseline.namedObject,
|
|
2757
|
+
alsoNamed: [...(baseline.alsoNamed ?? []), added.namedObject, ...(added.alsoNamed ?? [])],
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2655
2761
|
// ---------------------------------------------------------------------------
|
|
2656
2762
|
// Is one policy's rule inside the translation
|
|
2657
2763
|
// ---------------------------------------------------------------------------
|
|
@@ -3311,7 +3417,9 @@ function isNamedObject(
|
|
|
3311
3417
|
kind: EgressPolicyDocument['kind'],
|
|
3312
3418
|
name: string,
|
|
3313
3419
|
): boolean {
|
|
3314
|
-
|
|
3420
|
+
const matches = (entry: { readonly kind: string; readonly name: string }): boolean =>
|
|
3421
|
+
entry.kind === kind && entry.name === name
|
|
3422
|
+
return matches(allowance.namedObject) || (allowance.alsoNamed ?? []).some(matches)
|
|
3315
3423
|
}
|
|
3316
3424
|
|
|
3317
3425
|
/** Every core `NetworkPolicy` in the list, reduced to {@link EgressPolicyDocument}. */
|
|
@@ -3714,7 +3822,22 @@ export async function verifyEgressPolicyUnion(
|
|
|
3714
3822
|
target: EgressVerificationTarget,
|
|
3715
3823
|
signal?: AbortSignal,
|
|
3716
3824
|
): Promise<void> {
|
|
3717
|
-
|
|
3825
|
+
await verifyEgressPolicyUnionAgainst(client, egressAllowance(translated), target, signal)
|
|
3826
|
+
}
|
|
3827
|
+
|
|
3828
|
+
/**
|
|
3829
|
+
* {@link verifyEgressPolicyUnion} against an allowance the caller already
|
|
3830
|
+
* built — the entry point for a question about more than one translation,
|
|
3831
|
+
* which is what the post-write check of a per-sandbox policy is: the pod's
|
|
3832
|
+
* boundary is the baseline's allowance plus the written policy's, see
|
|
3833
|
+
* {@link unionEgressAllowance}.
|
|
3834
|
+
*/
|
|
3835
|
+
export async function verifyEgressPolicyUnionAgainst(
|
|
3836
|
+
client: KubernetesClient,
|
|
3837
|
+
allowance: EgressAllowance,
|
|
3838
|
+
target: EgressVerificationTarget,
|
|
3839
|
+
signal?: AbortSignal,
|
|
3840
|
+
): Promise<void> {
|
|
3718
3841
|
const documents: EgressPolicyDocument[] = []
|
|
3719
3842
|
try {
|
|
3720
3843
|
documents.push(
|
|
@@ -53,6 +53,81 @@ export interface KubernetesWorkspaceIdentity {
|
|
|
53
53
|
readonly guestBootId: string | undefined
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The identity a host retained for a workspace, to be authenticated against
|
|
58
|
+
* what stands under the workspace's deterministic name before anything is
|
|
59
|
+
* written or reported.
|
|
60
|
+
*
|
|
61
|
+
* - `sandboxUid` — the Sandbox object. The decisive field: a delete and
|
|
62
|
+
* re-create under the same id changes it, and the disk with it.
|
|
63
|
+
* - `volumeClaimUids` — optional, keyed by `volumeClaimTemplates` entry name.
|
|
64
|
+
* Checked by {@link openKubernetesWorkspace} and
|
|
65
|
+
* {@link reconcileKubernetesWorkspace}; an entry that is absent from the
|
|
66
|
+
* cluster is "not there yet", not "replaced". Lifecycle writes pin the
|
|
67
|
+
* Sandbox uid alone, because the claims are owned by the Sandbox.
|
|
68
|
+
*/
|
|
69
|
+
export interface KubernetesWorkspaceExpectedIdentity {
|
|
70
|
+
readonly sandboxUid: string
|
|
71
|
+
readonly volumeClaimUids?: Readonly<Record<string, string>>
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Which part of a retained identity did not hold. */
|
|
75
|
+
export type KubernetesWorkspaceIdentityMismatch =
|
|
76
|
+
| 'sandbox-absent'
|
|
77
|
+
| 'sandbox-uid'
|
|
78
|
+
| 'volume-claim-uid'
|
|
79
|
+
| 'volume-claim-owner'
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Thrown by {@link openKubernetesWorkspace} when what stands under the
|
|
83
|
+
* workspace's name is not the workspace the host retained.
|
|
84
|
+
*
|
|
85
|
+
* The refusal is about OWNERSHIP and nothing else. A `resourceVersion` that
|
|
86
|
+
* moved, a `Ready` condition that flipped and a condition left over from an
|
|
87
|
+
* earlier generation are the controller doing its work and never raise this;
|
|
88
|
+
* it needs a different Sandbox uid, a Sandbox that is gone, a claim with a
|
|
89
|
+
* different uid than the one retained, or a claim owned by another Sandbox.
|
|
90
|
+
* Nothing was created, woken or written to reach it, and it is terminal:
|
|
91
|
+
* retrying finds the same stranger.
|
|
92
|
+
*/
|
|
93
|
+
export class KubernetesWorkspaceIdentityMismatchError extends Error {
|
|
94
|
+
override readonly name = 'KubernetesWorkspaceIdentityMismatchError'
|
|
95
|
+
|
|
96
|
+
constructor(
|
|
97
|
+
readonly workspaceId: string,
|
|
98
|
+
readonly sandboxName: string,
|
|
99
|
+
readonly mismatch: KubernetesWorkspaceIdentityMismatch,
|
|
100
|
+
/** The uid that was retained for the thing that differs. */
|
|
101
|
+
readonly expected: string | undefined,
|
|
102
|
+
/** The uid standing there now; `undefined` when nothing does. */
|
|
103
|
+
readonly actual: string | undefined,
|
|
104
|
+
/** The `volumeClaimTemplates` entry, for the two claim mismatches. */
|
|
105
|
+
readonly claim?: string,
|
|
106
|
+
) {
|
|
107
|
+
super(
|
|
108
|
+
`kubernetes: workspace ${workspaceId} (Sandbox ${sandboxName}) is not the workspace this host retained — ${describeMismatch(mismatch, expected, actual, claim)}. Nothing was created, woken or written. The name is deterministic, so what stands under it now is not the disk the retained identity names; decide what the other one is worth before using it.`,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function describeMismatch(
|
|
114
|
+
mismatch: KubernetesWorkspaceIdentityMismatch,
|
|
115
|
+
expected: string | undefined,
|
|
116
|
+
actual: string | undefined,
|
|
117
|
+
claim: string | undefined,
|
|
118
|
+
): string {
|
|
119
|
+
if (mismatch === 'sandbox-absent') {
|
|
120
|
+
return `no Sandbox stands there (retained uid ${String(expected)})`
|
|
121
|
+
}
|
|
122
|
+
if (mismatch === 'sandbox-uid') {
|
|
123
|
+
return `its Sandbox has uid ${String(actual)}, the retained one is ${String(expected)}`
|
|
124
|
+
}
|
|
125
|
+
if (mismatch === 'volume-claim-uid') {
|
|
126
|
+
return `its volume claim "${String(claim)}" has uid ${String(actual)}, the retained one is ${String(expected)}`
|
|
127
|
+
}
|
|
128
|
+
return `its volume claim "${String(claim)}" (uid ${String(actual)}) is owned by a different Sandbox than ${String(expected)}`
|
|
129
|
+
}
|
|
130
|
+
|
|
56
131
|
/**
|
|
57
132
|
* Why the guest a handle is talking to is not the guest it was talking to.
|
|
58
133
|
*
|
|
@@ -259,3 +334,138 @@ function describeEvidence(evidence: KubernetesGuestEvidence): string {
|
|
|
259
334
|
if (evidence === 'unknown') return 'the guest could not be identified at all'
|
|
260
335
|
return 'the guest is unchanged'
|
|
261
336
|
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The whole guest a caller authenticated, to be handed to
|
|
340
|
+
* `workspace.pinGuest()`: the retained workspace identity
|
|
341
|
+
* ({@link KubernetesWorkspaceExpectedIdentity}, the same shape
|
|
342
|
+
* `openKubernetesWorkspace` takes) plus the two things only the guest side
|
|
343
|
+
* can say — the pod the agent runs in and the agent process itself.
|
|
344
|
+
*
|
|
345
|
+
* Every one of `sandboxUid`, `podUid` and `guestBootId` is REQUIRED, and a
|
|
346
|
+
* missing or empty one is refused with {@link KubernetesPinIncompleteError}
|
|
347
|
+
* rather than treated as "any": a pin that quietly matched everything the
|
|
348
|
+
* caller left blank would authenticate nothing. Read them off
|
|
349
|
+
* `workspace.identity` once a call has been answered, because
|
|
350
|
+
* `guestBootId` is `undefined` until the guest has spoken.
|
|
351
|
+
*/
|
|
352
|
+
export interface KubernetesPinnedGuestIdentity extends KubernetesWorkspaceExpectedIdentity {
|
|
353
|
+
readonly podUid: string
|
|
354
|
+
readonly guestBootId: string
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* `workspace.pinGuest()` was given an identity that cannot pin anything.
|
|
359
|
+
* Thrown synchronously, before a view exists; nothing was sent.
|
|
360
|
+
*/
|
|
361
|
+
export class KubernetesPinIncompleteError extends Error {
|
|
362
|
+
override readonly name = 'KubernetesPinIncompleteError'
|
|
363
|
+
|
|
364
|
+
constructor(
|
|
365
|
+
/** The fields that were absent, not a non-empty string, or malformed. */
|
|
366
|
+
readonly missing: readonly string[],
|
|
367
|
+
) {
|
|
368
|
+
super(
|
|
369
|
+
`kubernetes: pinGuest() needs a complete guest identity and was given one without ${missing.join(', ')}. A pin that left a field blank would match whatever stands there, which authenticates nothing. Read sandboxUid, podUid and guestBootId from workspace.identity after a call has been answered by the guest you mean to pin.`,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Why a pinned view refused a call. */
|
|
375
|
+
export type KubernetesGuestPinRefusal =
|
|
376
|
+
/** The workspace's own identity no longer equals the pinned one. Checked before anything is dialed. */
|
|
377
|
+
| 'identity-changed'
|
|
378
|
+
/** The guest answered that it is a different agent process. It dispatched nothing. */
|
|
379
|
+
| 'guest-replaced'
|
|
380
|
+
/** The pod the view was pinned to refused its token: another pod stands there. */
|
|
381
|
+
| 'pod-replaced'
|
|
382
|
+
/** The guest agent is too old to authenticate a request, so a pin would silently not pin. Nothing was sent. */
|
|
383
|
+
| 'guest-unsupported'
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Thrown by every call on a pinned view when the guest behind the workspace
|
|
387
|
+
* is not the one the view was pinned to.
|
|
388
|
+
*
|
|
389
|
+
* It means the call did NOT run: either the host refused before dialing
|
|
390
|
+
* (`identity-changed`, `guest-unsupported`) or the guest agent compared its
|
|
391
|
+
* boot id first and dispatched nothing (`guest-replaced`), or the token was
|
|
392
|
+
* refused unread (`pod-replaced`). The view never rebinds, so the same call
|
|
393
|
+
* will keep refusing; pin the new guest if that is what you want.
|
|
394
|
+
*
|
|
395
|
+
* `freshGuestBootId` is the id of the agent that refused, when it said:
|
|
396
|
+
* different from `expected.guestBootId` means replaced, and an agent that
|
|
397
|
+
* answers `KubernetesAgentRetiringError` with the SAME id is fenced, not
|
|
398
|
+
* replaced.
|
|
399
|
+
*/
|
|
400
|
+
export class KubernetesGuestPinRefusedError extends Error {
|
|
401
|
+
override readonly name = 'KubernetesGuestPinRefusedError'
|
|
402
|
+
|
|
403
|
+
constructor(
|
|
404
|
+
readonly reason: KubernetesGuestPinRefusal,
|
|
405
|
+
readonly workspaceId: string,
|
|
406
|
+
readonly expected: KubernetesPinnedGuestIdentity,
|
|
407
|
+
/** The workspace's identity at the time, for `identity-changed`. */
|
|
408
|
+
readonly actual: KubernetesWorkspaceIdentity | undefined,
|
|
409
|
+
/** Which pinned field differed, for `identity-changed`. */
|
|
410
|
+
readonly field: 'sandboxUid' | 'volumeClaimUids' | 'podUid' | 'guestBootId' | undefined,
|
|
411
|
+
/** The boot id of the agent that answered, for `guest-replaced`. */
|
|
412
|
+
readonly freshGuestBootId: string | undefined,
|
|
413
|
+
options?: ErrorOptions,
|
|
414
|
+
) {
|
|
415
|
+
super(describePinRefusal(reason, workspaceId, expected, field, freshGuestBootId), options)
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function describePinRefusal(
|
|
420
|
+
reason: KubernetesGuestPinRefusal,
|
|
421
|
+
workspaceId: string,
|
|
422
|
+
expected: KubernetesPinnedGuestIdentity,
|
|
423
|
+
field: string | undefined,
|
|
424
|
+
fresh: string | undefined,
|
|
425
|
+
): string {
|
|
426
|
+
const head = `kubernetes: this view of workspace ${workspaceId} is pinned to guest ${expected.guestBootId} in pod ${expected.podUid}`
|
|
427
|
+
if (reason === 'identity-changed') {
|
|
428
|
+
return `${head}, and the workspace's ${field ?? 'identity'} is no longer the pinned one. Nothing was sent. A pinned view never rebinds; pin the new guest if that is the one you mean.`
|
|
429
|
+
}
|
|
430
|
+
if (reason === 'guest-replaced') {
|
|
431
|
+
return `${head}, but the agent that answered is ${fresh ?? 'a different process'}. It compared its boot id before dispatching, so nothing ran. A pinned view never rebinds; pin the new guest if that is the one you mean.`
|
|
432
|
+
}
|
|
433
|
+
if (reason === 'pod-replaced') {
|
|
434
|
+
return `${head}, but that pod refused the pinned token, so a different pod stands there now. Nothing ran. A pinned view never rebinds; pin the new guest if that is the one you mean.`
|
|
435
|
+
}
|
|
436
|
+
return `${head}, but its guest agent does not advertise the 'pinned-guest' feature, so it would serve a request on whatever guest it is. Nothing was sent. Rebuild the workspace image from this Namzu release.`
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** A non-empty string, which is what every pinned identity field must be. */
|
|
440
|
+
function isIdentityString(value: unknown): value is string {
|
|
441
|
+
return typeof value === 'string' && value !== ''
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Validate and freeze a pin request, copying it first: the caller's object is
|
|
446
|
+
* not read again after this returns, so mutating it cannot loosen a pin.
|
|
447
|
+
*/
|
|
448
|
+
export function freezePinnedIdentity(
|
|
449
|
+
input: KubernetesPinnedGuestIdentity,
|
|
450
|
+
): KubernetesPinnedGuestIdentity {
|
|
451
|
+
const source = (input ?? {}) as Partial<KubernetesPinnedGuestIdentity>
|
|
452
|
+
const missing: string[] = []
|
|
453
|
+
if (!isIdentityString(source.sandboxUid)) missing.push('sandboxUid')
|
|
454
|
+
if (!isIdentityString(source.podUid)) missing.push('podUid')
|
|
455
|
+
if (!isIdentityString(source.guestBootId)) missing.push('guestBootId')
|
|
456
|
+
let claims: Record<string, string> | undefined
|
|
457
|
+
if (source.volumeClaimUids !== undefined) {
|
|
458
|
+
claims = {}
|
|
459
|
+
for (const [name, uid] of Object.entries(source.volumeClaimUids)) {
|
|
460
|
+
if (!isIdentityString(uid)) missing.push(`volumeClaimUids.${name}`)
|
|
461
|
+
else claims[name] = uid
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
if (missing.length > 0) throw new KubernetesPinIncompleteError(missing)
|
|
465
|
+
return Object.freeze({
|
|
466
|
+
sandboxUid: source.sandboxUid as string,
|
|
467
|
+
podUid: source.podUid as string,
|
|
468
|
+
guestBootId: source.guestBootId as string,
|
|
469
|
+
...(claims !== undefined ? { volumeClaimUids: Object.freeze(claims) } : {}),
|
|
470
|
+
})
|
|
471
|
+
}
|
|
@@ -126,12 +126,15 @@ import {
|
|
|
126
126
|
assertPerSandboxEgressIsUsable,
|
|
127
127
|
composeAdditionalPodLabels,
|
|
128
128
|
defaultEgressPolicyName,
|
|
129
|
+
egressAllowance,
|
|
129
130
|
egressProfileLabel,
|
|
130
131
|
egressUnionVerificationEnabled,
|
|
131
132
|
perSandboxEgressLabelKey,
|
|
132
133
|
translateEgressPolicy,
|
|
134
|
+
unionEgressAllowance,
|
|
133
135
|
verifyEgressPolicyApplied,
|
|
134
136
|
verifyEgressPolicyUnion,
|
|
137
|
+
verifyEgressPolicyUnionAgainst,
|
|
135
138
|
} from './egress-policy.js'
|
|
136
139
|
import {
|
|
137
140
|
type KubernetesIngressConfig,
|
|
@@ -430,6 +433,13 @@ export interface KubernetesAcquisition {
|
|
|
430
433
|
* {@link owner}.
|
|
431
434
|
*/
|
|
432
435
|
readonly perSandboxLabelValue?: string
|
|
436
|
+
/**
|
|
437
|
+
* The bound pod's own labels, as the API server reported them — what a
|
|
438
|
+
* policy's selector actually matches. Present under the same condition as
|
|
439
|
+
* {@link owner}; the post-write union check enumerates the policies that
|
|
440
|
+
* select THESE labels.
|
|
441
|
+
*/
|
|
442
|
+
readonly podLabels?: Readonly<Record<string, string>>
|
|
433
443
|
/** The TTL acquire stamped, which every renewal re-stamps. */
|
|
434
444
|
readonly ttlSeconds: number
|
|
435
445
|
/** DELETE that object. An already-gone object counts as released. */
|
|
@@ -628,7 +638,8 @@ export function buildKubernetesBackend(config: KubernetesBackendInternalConfig):
|
|
|
628
638
|
// CONFIGURATION and never a runtime probe, so a caller's capability
|
|
629
639
|
// detection cannot depend on when it asked.
|
|
630
640
|
const perSandbox = config.egress?.perSandbox
|
|
631
|
-
const fence =
|
|
641
|
+
const fence =
|
|
642
|
+
perSandbox === undefined ? undefined : buildAdmissionFence(client, perSandbox, config.namespace)
|
|
632
643
|
return {
|
|
633
644
|
tier: 'microvm',
|
|
634
645
|
name: 'kubernetes',
|
|
@@ -655,6 +666,17 @@ export function buildKubernetesBackend(config: KubernetesBackendInternalConfig):
|
|
|
655
666
|
egress: { ...egress, perSandbox: egress.perSandbox },
|
|
656
667
|
owner: acquisition.owner,
|
|
657
668
|
selectorValue: acquisition.perSandboxLabelValue,
|
|
669
|
+
...(egressBoundary !== undefined && acquisition.podLabels !== undefined
|
|
670
|
+
? {
|
|
671
|
+
verifyUnion: (written, signal) =>
|
|
672
|
+
egressBoundary.verifyUnionWith(
|
|
673
|
+
acquisition.podLabels ?? {},
|
|
674
|
+
`the per-sandbox policy ${written.name} written for Sandbox ${acquisition.owner?.name} in namespace ${config.namespace}`,
|
|
675
|
+
written,
|
|
676
|
+
signal,
|
|
677
|
+
),
|
|
678
|
+
}
|
|
679
|
+
: {}),
|
|
658
680
|
})
|
|
659
681
|
: undefined
|
|
660
682
|
return await admitProbedSandbox(
|
|
@@ -690,6 +712,12 @@ export interface KubernetesEgressBoundary {
|
|
|
690
712
|
* exact-match comparison and its once-per-boundary memo.
|
|
691
713
|
*/
|
|
692
714
|
verifyNamedObject(signal?: AbortSignal): Promise<void>
|
|
715
|
+
/**
|
|
716
|
+
* The translated policy itself — the same one-per-boundary translation
|
|
717
|
+
* both checks compare against. Read-only: nothing is created or fetched
|
|
718
|
+
* from the cluster. `resolveKubernetesExecutionProfile` fingerprints it.
|
|
719
|
+
*/
|
|
720
|
+
translatedPolicy(): Promise<KubernetesTranslatedEgressPolicy>
|
|
693
721
|
/**
|
|
694
722
|
* Enumerate every policy selecting THIS pod and refuse when any of them
|
|
695
723
|
* allows egress the translation does not. A no-op under
|
|
@@ -700,6 +728,20 @@ export interface KubernetesEgressBoundary {
|
|
|
700
728
|
subject: string,
|
|
701
729
|
signal?: AbortSignal,
|
|
702
730
|
): Promise<void>
|
|
731
|
+
/**
|
|
732
|
+
* The same enumeration after a per-sandbox policy was WRITTEN: refuse when
|
|
733
|
+
* any policy selecting this pod allows more than the baseline translation
|
|
734
|
+
* plus `added`. Never cached — it runs once per write, against the cluster
|
|
735
|
+
* as it is at that moment, because a policy applied between two writes is
|
|
736
|
+
* exactly what it exists to notice. A no-op under
|
|
737
|
+
* `egress.verify: 'named-object-only'`.
|
|
738
|
+
*/
|
|
739
|
+
verifyUnionWith(
|
|
740
|
+
podLabels: Readonly<Record<string, string>>,
|
|
741
|
+
subject: string,
|
|
742
|
+
added: KubernetesTranslatedEgressPolicy,
|
|
743
|
+
signal?: AbortSignal,
|
|
744
|
+
): Promise<void>
|
|
703
745
|
}
|
|
704
746
|
|
|
705
747
|
/**
|
|
@@ -776,6 +818,7 @@ export function buildEgressBoundary(
|
|
|
776
818
|
// resolves and a per-check throw would surface from a cache lookup.
|
|
777
819
|
const perSandboxKey = perSandboxEgressLabelKey(egress)
|
|
778
820
|
return {
|
|
821
|
+
translatedPolicy: translate,
|
|
779
822
|
async verifyNamedObject(signal) {
|
|
780
823
|
namedObject ??= (async () => {
|
|
781
824
|
await verifyEgressPolicyApplied(client, await translate(), signal)
|
|
@@ -808,6 +851,20 @@ export function buildEgressBoundary(
|
|
|
808
851
|
passes.set(key, { at: now(), pending })
|
|
809
852
|
await pending
|
|
810
853
|
},
|
|
854
|
+
async verifyUnionWith(podLabels, subject, added, signal) {
|
|
855
|
+
if (!egressUnionVerificationEnabled(egress)) return
|
|
856
|
+
const baseline = await translate()
|
|
857
|
+
await verifyEgressPolicyUnionAgainst(
|
|
858
|
+
client,
|
|
859
|
+
unionEgressAllowance(egressAllowance(baseline), egressAllowance(added)),
|
|
860
|
+
// 'cilium' whatever `egress.engine` says: the policy just written is
|
|
861
|
+
// a `CiliumNetworkPolicy`, so the Cilium collection has to be
|
|
862
|
+
// enumerated for a widening one to be seen at all. Under the
|
|
863
|
+
// default engine the create path reads core policies only.
|
|
864
|
+
{ namespace: config.namespace, podLabels, engine: 'cilium', subject },
|
|
865
|
+
signal,
|
|
866
|
+
)
|
|
867
|
+
},
|
|
811
868
|
}
|
|
812
869
|
}
|
|
813
870
|
|
|
@@ -1667,7 +1724,9 @@ export async function acquireKubernetesSandbox(
|
|
|
1667
1724
|
? { refreshAgent: buildAgentAddressRefresh(client, namespace, binding, agentPort, mode) }
|
|
1668
1725
|
: {}),
|
|
1669
1726
|
ownedPath,
|
|
1670
|
-
...(owner !== undefined
|
|
1727
|
+
...(owner !== undefined
|
|
1728
|
+
? { owner, perSandboxLabelValue: objectName, podLabels: pod.labels ?? podLabels }
|
|
1729
|
+
: {}),
|
|
1671
1730
|
ttlSeconds,
|
|
1672
1731
|
release,
|
|
1673
1732
|
renew,
|