@namzu/sandbox 26.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 +47 -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
|
@@ -159,13 +159,15 @@
|
|
|
159
159
|
*/
|
|
160
160
|
import { OperationDeadline, OperationDeadlineExpired, runFailureCleanup } from '../readiness.js';
|
|
161
161
|
import { RemoteCancellationUnknownError } from '../remote-execution-controller.js';
|
|
162
|
-
import { assertEgressPolicyIsEnforceable, assertEgressProfileIsUsable,
|
|
163
|
-
import { KubernetesWorkspaceGuestGoneError, KubernetesWorkspaceReplacedError, } from './identity.js';
|
|
162
|
+
import { assertEgressPolicyIsEnforceable, assertEgressProfileIsUsable, assertPerSandboxEgressIsUsable, composeAdditionalPodLabels, egressProfileLabel, } from './egress-policy.js';
|
|
163
|
+
import { KubernetesGuestPinRefusedError, KubernetesWorkspaceGuestGoneError, KubernetesWorkspaceReplacedError, freezePinnedIdentity, } from './identity.js';
|
|
164
164
|
import { DEFAULT_AGENT_PORT, ReadinessPollTimeout, bindingFromSandbox, buildEgressBoundary, buildIngressVerifier, buildSandboxBody, clientAccess, clientOptions, pollForBinding, probeSandboxPrivileges, readBoundPod, readSandboxTemplate, resolveAgentAddress, resolveKubernetesReadiness, resolveProbeTimeoutMs, resolveStreamHeartbeatMs, sandboxPodLabels, sandboxPodTemplate, } from './index.js';
|
|
165
165
|
import { KubernetesAlreadyGoneError, KubernetesConflictError, KubernetesPatchNotAppliedError, createKubernetesClient, } from './k8s-client.js';
|
|
166
166
|
import { HOLDER_EPOCH_ANNOTATION_KEY, OPERATING_MODE_CHANGED_AT_ANNOTATION_KEY, POD_TEMPLATE_HASH_ANNOTATION_KEY, SANDBOX_TEMPLATE_LABEL_KEY, buildHolderEpochPatch, holderEpochAllows, isPodStopped, persistentVolumeClaimPath, podPath, podTemplateHash, readHolderEpoch, readPodTemplateHash, sandboxCollectionPath, sandboxPath, } from './objects.js';
|
|
167
|
+
import { KubernetesOwnerUidMissingError, buildAdmissionFence, buildPerSandboxPolicySetter, } from './per-sandbox-policy.js';
|
|
167
168
|
import { KubernetesSandboxDestroyedError, buildKubernetesSandbox, } from './sandbox.js';
|
|
168
|
-
import { KubernetesAgentTransport, KubernetesFlushUnconfirmedError, KubernetesFlushUnreachableError, KubernetesFlushUnsupportedError, KubernetesQuiesceUnconfirmedError, KubernetesQuiesceUnsupportedError, flushUnsupportedError, guestWhenReserved, } from './transport.js';
|
|
169
|
+
import { KubernetesAgentTransport, KubernetesFlushUnconfirmedError, KubernetesFlushUnreachableError, KubernetesFlushUnsupportedError, KubernetesQuiesceUnconfirmedError, KubernetesQuiesceUnsupportedError, flushUnsupportedError, guestMismatchOf, guestWhenReserved, isUnauthorizedRefusal, } from './transport.js';
|
|
170
|
+
import { perSandboxWorkspaceLabels, reverifyPerSandboxEgress, withoutPerSandboxLabel, } from './workspace-per-sandbox.js';
|
|
169
171
|
/**
|
|
170
172
|
* Thrown when a workspace's `SandboxTemplate` does not describe a block disk
|
|
171
173
|
* this backend is willing to build a workspace on.
|
|
@@ -368,7 +370,7 @@ export class KubernetesWorkspacePreconditionError extends Error {
|
|
|
368
370
|
* as a decimal string, and an integer because a fractional epoch would
|
|
369
371
|
* round-trip through the annotation as something other than what was passed.
|
|
370
372
|
*/
|
|
371
|
-
function assertHolderEpoch(epoch, where) {
|
|
373
|
+
export function assertHolderEpoch(epoch, where) {
|
|
372
374
|
if (epoch === undefined)
|
|
373
375
|
return undefined;
|
|
374
376
|
if (!Number.isSafeInteger(epoch) || epoch < 0) {
|
|
@@ -611,6 +613,13 @@ export function assertAdoptedWorkspaceMatchesConfig(sandboxName, namespace, podT
|
|
|
611
613
|
throw new KubernetesWorkspaceMismatchError(sandboxName, 'egressProfile', `${profile.key}=${profile.value}`, carried === undefined ? undefined : `${profile.key}=${carried}`, `kubernetes: Sandbox ${sandboxName} in namespace ${namespace} already exists, and its spec.podTemplate carries ${profile.key}: ${carried === undefined ? '(absent)' : JSON.stringify(carried)} rather than the configured egress profile ${JSON.stringify(profile.value)}. The translated policy's podSelector matches that label as well as the template one, so this object's pod is not selected by the policy this call just verified — and since each profile needs its own policy object, a pod carrying no profile label at all is selected by none of them, while create() would have reported the boundary verified. A standing workspace's pod labels are not rewritten underneath it by an ordinary adopt, so the mismatch is named instead. Three ways out, and only the last one costs the disk: reopen it with refreshPodTemplate: true, which rewrites spec.podTemplate — profile label and all — on the one Suspended → Running transition and is the supported way to move a workspace between profiles; or point config.egress.profile at ${carried === undefined ? 'the profile this object was built under (none was)' : JSON.stringify(carried)}; or delete the Sandbox — which takes its disk with it — and create it again under ${JSON.stringify(profile.value)}.`);
|
|
612
614
|
}
|
|
613
615
|
}
|
|
616
|
+
const perSandboxLabel = expected.perSandboxLabel;
|
|
617
|
+
if (perSandboxLabel !== undefined) {
|
|
618
|
+
const carried = podTemplate?.metadata?.labels?.[perSandboxLabel.key];
|
|
619
|
+
if (carried !== perSandboxLabel.value) {
|
|
620
|
+
throw new KubernetesWorkspaceMismatchError(sandboxName, 'perSandboxLabel', `${perSandboxLabel.key}=${perSandboxLabel.value}`, carried === undefined ? undefined : `${perSandboxLabel.key}=${carried}`, `kubernetes: Sandbox ${sandboxName} in namespace ${namespace} already exists, and its spec.podTemplate carries ${perSandboxLabel.key}: ${carried === undefined ? '(absent)' : JSON.stringify(carried)} rather than ${JSON.stringify(perSandboxLabel.value)}. config.egress.perSandbox is configured, and every per-sandbox policy selects a pod by exactly that label — a workspace created before the option was set carries none, so a policy written for it would select nothing while setNetworkPolicy reported the grant applied. A standing workspace's pod labels are not rewritten underneath it by an ordinary adopt, so the mismatch is named instead. Reopen it with refreshPodTemplate: true, which rewrites spec.podTemplate — the label included — on the one Suspended → Running transition, or unset config.egress.perSandbox, or delete the Sandbox — which takes its disk with it — and create it again.`);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
614
623
|
if (expected.runtimeClassName === undefined)
|
|
615
624
|
return;
|
|
616
625
|
const declared = podTemplate?.spec?.runtimeClassName;
|
|
@@ -644,17 +653,15 @@ export function assertAdoptedWorkspaceMatchesConfig(sandboxName, namespace, podT
|
|
|
644
653
|
*/
|
|
645
654
|
export async function createKubernetesWorkspace(config, options) {
|
|
646
655
|
options.signal?.throwIfAborted();
|
|
647
|
-
// `config.egress.perSandbox` is
|
|
648
|
-
//
|
|
649
|
-
//
|
|
650
|
-
//
|
|
651
|
-
//
|
|
652
|
-
//
|
|
653
|
-
//
|
|
654
|
-
//
|
|
655
|
-
|
|
656
|
-
// check a config this path serves no purpose for and report it as usable.
|
|
657
|
-
assertWorkspaceCarriesNoPerSandboxEgress(config.egress);
|
|
656
|
+
// `config.egress.perSandbox` is honoured here exactly as on a task handle:
|
|
657
|
+
// the workspace's pod template carries the per-sandbox selector label, the
|
|
658
|
+
// Sandbox owns every policy written for it, and the handle serves
|
|
659
|
+
// `setNetworkPolicy`. A value the backend can never honour — an engine
|
|
660
|
+
// with no hostname concept, an unnamed fence, a selector key the API
|
|
661
|
+
// server would refuse — is decidable from config alone and is refused
|
|
662
|
+
// first, before readiness, before the client, before any request. With
|
|
663
|
+
// `perSandbox` unset this is a no-op and nothing below changes.
|
|
664
|
+
assertPerSandboxEgressIsUsable(config.egress);
|
|
658
665
|
const readiness = resolveKubernetesReadiness(config);
|
|
659
666
|
const namespace = config.namespace;
|
|
660
667
|
const name = workspaceSandboxName(options.workspaceId);
|
|
@@ -704,6 +711,17 @@ export async function createKubernetesWorkspace(config, options) {
|
|
|
704
711
|
assertEgressPolicyIsEnforceable(config.egress.policy, config.egress.engine ?? 'core', config.egress.ciliumNarrowing);
|
|
705
712
|
await egressBoundary?.verifyNamedObject(options.signal);
|
|
706
713
|
}
|
|
714
|
+
// Per-sandbox egress: the fence that bounds what this host may write is
|
|
715
|
+
// proved BEFORE the POST and before any adopt patch, so a workspace is
|
|
716
|
+
// never opened, woken or created under a configuration whose fence does
|
|
717
|
+
// not enforce. `undefined` — and not one extra request — without it.
|
|
718
|
+
const perSandboxEgress = config.egress?.perSandbox !== undefined
|
|
719
|
+
? { ...config.egress, perSandbox: config.egress.perSandbox }
|
|
720
|
+
: undefined;
|
|
721
|
+
const openFence = perSandboxEgress !== undefined
|
|
722
|
+
? buildAdmissionFence(client, perSandboxEgress.perSandbox, namespace)
|
|
723
|
+
: undefined;
|
|
724
|
+
await openFence?.assertApplied(options.signal);
|
|
707
725
|
// Read and validate BEFORE anything is created, so a template that cannot
|
|
708
726
|
// carry a workspace fails with nothing to clean up.
|
|
709
727
|
const template = await readSandboxTemplate(client, namespace, templateName, options.signal);
|
|
@@ -730,7 +748,8 @@ export async function createKubernetesWorkspace(config, options) {
|
|
|
730
748
|
// workspace onto a new profile is `refreshPodTemplate: true`, which
|
|
731
749
|
// rewrites `/spec/podTemplate` with exactly these labels.
|
|
732
750
|
const profile = egressProfileLabel(config.egress);
|
|
733
|
-
const
|
|
751
|
+
const perSandboxLabels = perSandboxWorkspaceLabels(config.egress, name);
|
|
752
|
+
const profilePodLabels = composeAdditionalPodLabels(config.egress, perSandboxLabels);
|
|
734
753
|
const workspacePodLabels = sandboxPodLabels(template, templateName, profilePodLabels);
|
|
735
754
|
const workspaceSubject = `to open workspace ${options.workspaceId} as Sandbox ${name} in namespace ${namespace}`;
|
|
736
755
|
const verifyIngress = buildIngressVerifier(client, config);
|
|
@@ -744,7 +763,14 @@ export async function createKubernetesWorkspace(config, options) {
|
|
|
744
763
|
// reason the ingress check is, and on the adopt path before any resume
|
|
745
764
|
// patch — a workspace whose egress stopped being bounded is refused asleep
|
|
746
765
|
// rather than woken up to be refused.
|
|
747
|
-
|
|
766
|
+
// Without the per-sandbox label when one is configured: the only policy
|
|
767
|
+
// that selects a pod BY that key and value is this workspace's own, which
|
|
768
|
+
// a standing workspace already has and which the post-write union check
|
|
769
|
+
// (against the full labels) judged when it was written. Judged here
|
|
770
|
+
// against the BASELINE it would read as a widening and refuse to reopen
|
|
771
|
+
// every workspace that holds a grant.
|
|
772
|
+
const unionLabels = withoutPerSandboxLabel(workspacePodLabels, perSandboxLabels);
|
|
773
|
+
await egressBoundary?.verifyUnion(unionLabels, workspaceSubject, options.signal);
|
|
748
774
|
// The pod template this call would write, and its revision — built once,
|
|
749
775
|
// from the same overlays `buildSandboxBody` applies below, because the
|
|
750
776
|
// hash has to be taken over exactly the object that lands on the cluster.
|
|
@@ -754,8 +780,9 @@ export async function createKubernetesWorkspace(config, options) {
|
|
|
754
780
|
// by the POST would be taken over a template the POST never wrote.
|
|
755
781
|
const refresh = buildPodTemplateRefresh(template, templateName, config.runtimeClassName, profilePodLabels);
|
|
756
782
|
let adopted;
|
|
783
|
+
let createdUid;
|
|
757
784
|
try {
|
|
758
|
-
await client.request('POST', sandboxCollectionPath(namespace),
|
|
785
|
+
const created = await client.request('POST', sandboxCollectionPath(namespace),
|
|
759
786
|
// No shutdownTime: a workspace carries no expiry — see the module
|
|
760
787
|
// comment.
|
|
761
788
|
buildSandboxBody({
|
|
@@ -779,6 +806,7 @@ export async function createKubernetesWorkspace(config, options) {
|
|
|
779
806
|
? { runtimeClassName: config.runtimeClassName }
|
|
780
807
|
: {}),
|
|
781
808
|
}), options.signal);
|
|
809
|
+
createdUid = created?.metadata?.uid;
|
|
782
810
|
}
|
|
783
811
|
catch (err) {
|
|
784
812
|
if (!(err instanceof KubernetesConflictError))
|
|
@@ -789,10 +817,50 @@ export async function createKubernetesWorkspace(config, options) {
|
|
|
789
817
|
// were built from, so the object is checked against exactly
|
|
790
818
|
// the label this call would have stamped.
|
|
791
819
|
...(profile !== undefined ? { profile } : {}),
|
|
820
|
+
...(perSandboxLabels !== undefined
|
|
821
|
+
? {
|
|
822
|
+
perSandboxLabel: {
|
|
823
|
+
key: Object.keys(perSandboxLabels)[0],
|
|
824
|
+
value: name,
|
|
825
|
+
},
|
|
826
|
+
}
|
|
827
|
+
: {}),
|
|
792
828
|
...(config.runtimeClassName !== undefined
|
|
793
829
|
? { runtimeClassName: config.runtimeClassName }
|
|
794
830
|
: {}),
|
|
795
|
-
}, epoch, options.refreshPodTemplate === true ? refresh : undefined, options.signal
|
|
831
|
+
}, epoch, options.refreshPodTemplate === true ? refresh : undefined, options.signal, perSandboxEgress !== undefined && openFence !== undefined
|
|
832
|
+
? async (uid) => {
|
|
833
|
+
await reverifyPerSandboxEgress({
|
|
834
|
+
client,
|
|
835
|
+
namespace,
|
|
836
|
+
sandboxName: name,
|
|
837
|
+
ownerUid: uid,
|
|
838
|
+
egress: perSandboxEgress,
|
|
839
|
+
fence: openFence,
|
|
840
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
841
|
+
});
|
|
842
|
+
}
|
|
843
|
+
: undefined);
|
|
844
|
+
}
|
|
845
|
+
// A workspace this call just created has no policy of its own yet, but a
|
|
846
|
+
// same-named one deleted a moment ago may have left one the garbage
|
|
847
|
+
// collector has not reached: it selects this label value, so it is
|
|
848
|
+
// dropped now that the new uid is known. A reply with no uid skips it, and
|
|
849
|
+
// the handle refuses `setNetworkPolicy` for the same reason.
|
|
850
|
+
if (adopted === undefined &&
|
|
851
|
+
perSandboxEgress !== undefined &&
|
|
852
|
+
openFence !== undefined &&
|
|
853
|
+
typeof createdUid === 'string' &&
|
|
854
|
+
createdUid !== '') {
|
|
855
|
+
await reverifyPerSandboxEgress({
|
|
856
|
+
client,
|
|
857
|
+
namespace,
|
|
858
|
+
sandboxName: name,
|
|
859
|
+
ownerUid: createdUid,
|
|
860
|
+
egress: perSandboxEgress,
|
|
861
|
+
fence: openFence,
|
|
862
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
863
|
+
});
|
|
796
864
|
}
|
|
797
865
|
return await openWorkspaceHandle({
|
|
798
866
|
client,
|
|
@@ -813,6 +881,15 @@ export async function createKubernetesWorkspace(config, options) {
|
|
|
813
881
|
// would patch the egress profile off the very pod the configured
|
|
814
882
|
// policy selects.
|
|
815
883
|
...(Object.keys(profilePodLabels).length > 0 ? { podLabels: profilePodLabels } : {}),
|
|
884
|
+
...(perSandboxEgress !== undefined
|
|
885
|
+
? {
|
|
886
|
+
perSandboxEgress: {
|
|
887
|
+
egress: perSandboxEgress,
|
|
888
|
+
boundary: egressBoundary,
|
|
889
|
+
podLabels: workspacePodLabels,
|
|
890
|
+
},
|
|
891
|
+
}
|
|
892
|
+
: {}),
|
|
816
893
|
// A create wrote the revision it just computed; an adopt reports
|
|
817
894
|
// whatever the standing object carries, which is `undefined` for one
|
|
818
895
|
// created before the annotation existed.
|
|
@@ -867,7 +944,7 @@ export async function createKubernetesWorkspace(config, options) {
|
|
|
867
944
|
* been created yet, and the first bind attempt has to WAIT for it rather than
|
|
868
945
|
* fail on the read that finds no live pod — see {@link PodBindPolicy}.
|
|
869
946
|
*/
|
|
870
|
-
async function adoptExistingWorkspace(client, namespace, name, workspaceId, expected, epoch, refresh, signal) {
|
|
947
|
+
async function adoptExistingWorkspace(client, namespace, name, workspaceId, expected, epoch, refresh, signal, reverifyPerSandbox) {
|
|
871
948
|
const target = { client, namespace, name, workspaceId };
|
|
872
949
|
const existing = await readSandboxObject(target, signal);
|
|
873
950
|
assertBlockModeWorkspaceDisk(`Sandbox ${name} in namespace ${namespace}`, existing?.spec?.podTemplate, existing?.spec?.volumeClaimTemplates);
|
|
@@ -892,6 +969,9 @@ async function adoptExistingWorkspace(client, namespace, name, workspaceId, expe
|
|
|
892
969
|
? {}
|
|
893
970
|
: {
|
|
894
971
|
...(expected.profile !== undefined ? { profile: expected.profile } : {}),
|
|
972
|
+
...(expected.perSandboxLabel !== undefined
|
|
973
|
+
? { perSandboxLabel: expected.perSandboxLabel }
|
|
974
|
+
: {}),
|
|
895
975
|
...(expected.runtimeClassName !== undefined
|
|
896
976
|
? { runtimeClassName: expected.runtimeClassName }
|
|
897
977
|
: {}),
|
|
@@ -903,6 +983,17 @@ async function adoptExistingWorkspace(client, namespace, name, workspaceId, expe
|
|
|
903
983
|
// superseded opener patches nothing and starts no pod.
|
|
904
984
|
if (epoch !== undefined)
|
|
905
985
|
assertHolderEpochAllows(target, 'createKubernetesWorkspace', epoch, reading);
|
|
986
|
+
// Per-sandbox egress, before any patch for the reason the refusals above
|
|
987
|
+
// are: the fence is proved again and every policy this Sandbox does not
|
|
988
|
+
// own is dropped, so a workspace is never woken under a grant it did not
|
|
989
|
+
// consent to. See `workspace-per-sandbox.ts` for the rule.
|
|
990
|
+
if (reverifyPerSandbox !== undefined) {
|
|
991
|
+
const uid = existing?.metadata?.uid;
|
|
992
|
+
if (typeof uid !== 'string' || uid === '') {
|
|
993
|
+
throw new KubernetesOwnerUidMissingError(name, namespace);
|
|
994
|
+
}
|
|
995
|
+
await reverifyPerSandbox(uid);
|
|
996
|
+
}
|
|
906
997
|
if (refreshing && refresh !== undefined) {
|
|
907
998
|
// Both BEFORE the patch, and both against the SANDBOX's
|
|
908
999
|
// volumeClaimTemplates rather than the template's: those are
|
|
@@ -1041,6 +1132,36 @@ function operatingModePatch(mode) {
|
|
|
1041
1132
|
* re-read and then succeeds.
|
|
1042
1133
|
*/
|
|
1043
1134
|
const HOLDER_EPOCH_WRITE_ATTEMPTS = 3;
|
|
1135
|
+
/** The `expectedSandboxUid` a standalone verb's options carry, validated. */
|
|
1136
|
+
function expectedUidOf(options) {
|
|
1137
|
+
const identity = options?.expectedIdentity;
|
|
1138
|
+
if (identity === undefined)
|
|
1139
|
+
return {};
|
|
1140
|
+
if (typeof identity.sandboxUid !== 'string' || identity.sandboxUid === '') {
|
|
1141
|
+
throw new TypeError('kubernetes: expectedIdentity.sandboxUid must be a non-empty string — an empty uid would be a fence that fences nothing.');
|
|
1142
|
+
}
|
|
1143
|
+
return { expectedSandboxUid: identity.sandboxUid };
|
|
1144
|
+
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Refuse a write whose target is not the Sandbox the caller meant.
|
|
1147
|
+
*
|
|
1148
|
+
* Decided off the read the write is conditioned on, so the refusal is raised
|
|
1149
|
+
* BEFORE any request that could mutate. The same uid then rides in the write
|
|
1150
|
+
* itself, which is what makes the check hold against a replacement landing
|
|
1151
|
+
* between this read and that write.
|
|
1152
|
+
*/
|
|
1153
|
+
function assertSandboxUidStands(target, standing, options) {
|
|
1154
|
+
const expected = target.expectedSandboxUid;
|
|
1155
|
+
if (expected === undefined || standing === expected)
|
|
1156
|
+
return;
|
|
1157
|
+
throw new KubernetesWorkspaceReplacedError(target.workspaceId, target.name, expected, standing, options);
|
|
1158
|
+
}
|
|
1159
|
+
/** The `test` clause that pins a JSON patch to the Sandbox the caller meant. */
|
|
1160
|
+
function sandboxUidTests(target) {
|
|
1161
|
+
return target.expectedSandboxUid === undefined
|
|
1162
|
+
? []
|
|
1163
|
+
: [{ op: 'test', path: '/metadata/uid', value: target.expectedSandboxUid }];
|
|
1164
|
+
}
|
|
1044
1165
|
/** One GET of the Sandbox, which is where every condition is read from. */
|
|
1045
1166
|
async function readSandboxObject(target, signal) {
|
|
1046
1167
|
return await target.client.request('GET', sandboxPath(target.namespace, target.name), undefined, signal);
|
|
@@ -1104,7 +1225,11 @@ async function readHolderEpochGate(target, operation, epoch, signal) {
|
|
|
1104
1225
|
*/
|
|
1105
1226
|
async function writeOperatingMode(target, operation, mode, epoch, signal, gate) {
|
|
1106
1227
|
const path = sandboxPath(target.namespace, target.name);
|
|
1107
|
-
|
|
1228
|
+
// Nothing to write is nothing to condition: the stamp-only write exists to
|
|
1229
|
+
// move the epoch, and without one it has no mutation in it at all.
|
|
1230
|
+
if (epoch === undefined && mode === undefined)
|
|
1231
|
+
return;
|
|
1232
|
+
if (epoch === undefined && target.expectedSandboxUid === undefined) {
|
|
1108
1233
|
if (mode === undefined)
|
|
1109
1234
|
return;
|
|
1110
1235
|
await target.client.request('PATCH', path, operatingModePatch(mode), signal);
|
|
@@ -1112,10 +1237,15 @@ async function writeOperatingMode(target, operation, mode, epoch, signal, gate)
|
|
|
1112
1237
|
}
|
|
1113
1238
|
let reading = gate ?? readHolderEpoch((await readSandboxObject(target, signal))?.metadata);
|
|
1114
1239
|
for (let attempt = 1;; attempt += 1) {
|
|
1115
|
-
|
|
1240
|
+
// Identity before epoch: a stranger under this name is not this
|
|
1241
|
+
// caller's workspace, whatever epoch it happens to carry.
|
|
1242
|
+
assertSandboxUidStands(target, reading.sandboxUid);
|
|
1243
|
+
if (epoch !== undefined)
|
|
1244
|
+
assertHolderEpochAllows(target, operation, epoch, reading);
|
|
1116
1245
|
const patch = buildHolderEpochPatch({
|
|
1117
1246
|
reading,
|
|
1118
|
-
epoch,
|
|
1247
|
+
...(epoch !== undefined ? { epoch } : {}),
|
|
1248
|
+
tests: sandboxUidTests(target),
|
|
1119
1249
|
...(mode !== undefined
|
|
1120
1250
|
? { operatingMode: mode, operatingModeChangedAt: new Date().toISOString() }
|
|
1121
1251
|
: {}),
|
|
@@ -1130,6 +1260,10 @@ async function writeOperatingMode(target, operation, mode, epoch, signal, gate)
|
|
|
1130
1260
|
if (attempt >= HOLDER_EPOCH_WRITE_ATTEMPTS)
|
|
1131
1261
|
throw err;
|
|
1132
1262
|
const next = readHolderEpoch((await readSandboxObject(target, signal))?.metadata);
|
|
1263
|
+
// A replacement that landed between the read and the write is what
|
|
1264
|
+
// the uid `test` refused. It is never retried against the new
|
|
1265
|
+
// owner: this is the refusal, not a race to win.
|
|
1266
|
+
assertSandboxUidStands(target, next.sandboxUid);
|
|
1133
1267
|
const unmoved = next.annotation === reading.annotation &&
|
|
1134
1268
|
next.resourceVersion === reading.resourceVersion &&
|
|
1135
1269
|
next.hasAnnotations === reading.hasAnnotations;
|
|
@@ -1151,7 +1285,7 @@ async function writeOperatingMode(target, operation, mode, epoch, signal, gate)
|
|
|
1151
1285
|
* every create POSTs, so `templateCurrent` would report drift that no refresh
|
|
1152
1286
|
* could clear.
|
|
1153
1287
|
*/
|
|
1154
|
-
function buildPodTemplateRefresh(template, sandboxTemplateName, runtimeClassName, podLabels) {
|
|
1288
|
+
export function buildPodTemplateRefresh(template, sandboxTemplateName, runtimeClassName, podLabels) {
|
|
1155
1289
|
const podTemplate = sandboxPodTemplate(template, sandboxTemplateName, runtimeClassName, podLabels);
|
|
1156
1290
|
return {
|
|
1157
1291
|
podTemplate,
|
|
@@ -1196,12 +1330,16 @@ async function writeRefreshedPodTemplate(target, operation, refresh, epoch, gate
|
|
|
1196
1330
|
const path = sandboxPath(target.namespace, target.name);
|
|
1197
1331
|
let reading = gate;
|
|
1198
1332
|
for (let attempt = 1;; attempt += 1) {
|
|
1333
|
+
assertSandboxUidStands(target, reading.sandboxUid);
|
|
1199
1334
|
if (epoch !== undefined)
|
|
1200
1335
|
assertHolderEpochAllows(target, operation, epoch, reading);
|
|
1201
1336
|
const patch = buildHolderEpochPatch({
|
|
1202
1337
|
reading,
|
|
1203
1338
|
...(epoch !== undefined ? { epoch } : {}),
|
|
1204
|
-
tests: [
|
|
1339
|
+
tests: [
|
|
1340
|
+
...sandboxUidTests(target),
|
|
1341
|
+
{ op: 'test', path: '/spec/operatingMode', value: 'Suspended' },
|
|
1342
|
+
],
|
|
1205
1343
|
annotations: { [POD_TEMPLATE_HASH_ANNOTATION_KEY]: refresh.hash },
|
|
1206
1344
|
podTemplate: refresh.podTemplate,
|
|
1207
1345
|
operatingMode: 'Running',
|
|
@@ -1229,6 +1367,7 @@ async function writeRefreshedPodTemplate(target, operation, refresh, epoch, gate
|
|
|
1229
1367
|
if (sandbox === undefined)
|
|
1230
1368
|
throw err;
|
|
1231
1369
|
const next = readHolderEpoch(sandbox.metadata);
|
|
1370
|
+
assertSandboxUidStands(target, next.sandboxUid);
|
|
1232
1371
|
if (operatingModeOf(sandbox) !== 'Suspended') {
|
|
1233
1372
|
// Somebody resumed it first. If they also took the workspace
|
|
1234
1373
|
// over, this caller hears THAT rather than being handed a pod
|
|
@@ -1266,7 +1405,8 @@ async function writeRefreshedPodTemplate(target, operation, refresh, epoch, gate
|
|
|
1266
1405
|
*/
|
|
1267
1406
|
async function deleteSandboxObject(target, operation, epoch, signal, gate) {
|
|
1268
1407
|
const path = sandboxPath(target.namespace, target.name);
|
|
1269
|
-
|
|
1408
|
+
const expectedUid = target.expectedSandboxUid;
|
|
1409
|
+
if (epoch === undefined && expectedUid === undefined) {
|
|
1270
1410
|
try {
|
|
1271
1411
|
await target.client.request('DELETE', path, undefined, signal);
|
|
1272
1412
|
}
|
|
@@ -1278,7 +1418,9 @@ async function deleteSandboxObject(target, operation, epoch, signal, gate) {
|
|
|
1278
1418
|
}
|
|
1279
1419
|
let reading = gate;
|
|
1280
1420
|
for (let attempt = 1;; attempt += 1) {
|
|
1281
|
-
|
|
1421
|
+
// The epoch needs the object's current version for its precondition;
|
|
1422
|
+
// the uid alone does not, because it is the precondition.
|
|
1423
|
+
if (reading === undefined && epoch !== undefined) {
|
|
1282
1424
|
let sandbox;
|
|
1283
1425
|
try {
|
|
1284
1426
|
sandbox = await readSandboxObject(target, signal);
|
|
@@ -1290,8 +1432,12 @@ async function deleteSandboxObject(target, operation, epoch, signal, gate) {
|
|
|
1290
1432
|
}
|
|
1291
1433
|
reading = readHolderEpoch(sandbox?.metadata);
|
|
1292
1434
|
}
|
|
1293
|
-
|
|
1294
|
-
|
|
1435
|
+
if (reading !== undefined) {
|
|
1436
|
+
assertSandboxUidStands(target, reading.sandboxUid);
|
|
1437
|
+
if (epoch !== undefined)
|
|
1438
|
+
assertHolderEpochAllows(target, operation, epoch, reading);
|
|
1439
|
+
}
|
|
1440
|
+
if (epoch !== undefined && reading?.resourceVersion === undefined) {
|
|
1295
1441
|
// Unreachable against a real API server, which sets it on every
|
|
1296
1442
|
// object it serves — and a bodyless DELETE here would be an
|
|
1297
1443
|
// UNFENCED delete of somebody's disk, which is the one thing this
|
|
@@ -1302,7 +1448,12 @@ async function deleteSandboxObject(target, operation, epoch, signal, gate) {
|
|
|
1302
1448
|
await target.client.request('DELETE', path, {
|
|
1303
1449
|
apiVersion: 'v1',
|
|
1304
1450
|
kind: 'DeleteOptions',
|
|
1305
|
-
preconditions: {
|
|
1451
|
+
preconditions: {
|
|
1452
|
+
...(reading?.resourceVersion !== undefined
|
|
1453
|
+
? { resourceVersion: reading.resourceVersion }
|
|
1454
|
+
: {}),
|
|
1455
|
+
...(expectedUid !== undefined ? { uid: expectedUid } : {}),
|
|
1456
|
+
},
|
|
1306
1457
|
}, signal);
|
|
1307
1458
|
return;
|
|
1308
1459
|
}
|
|
@@ -1311,9 +1462,25 @@ async function deleteSandboxObject(target, operation, epoch, signal, gate) {
|
|
|
1311
1462
|
return;
|
|
1312
1463
|
if (!(err instanceof KubernetesConflictError))
|
|
1313
1464
|
throw err;
|
|
1465
|
+
// A 409 is either the version moving (epoch path: re-read and try
|
|
1466
|
+
// again) or the uid precondition refusing a stranger. Look, and
|
|
1467
|
+
// let the uid decide: a different owner is the refusal and is
|
|
1468
|
+
// never retried against.
|
|
1469
|
+
let standing;
|
|
1470
|
+
try {
|
|
1471
|
+
standing = await readSandboxObject(target, signal);
|
|
1472
|
+
}
|
|
1473
|
+
catch (readErr) {
|
|
1474
|
+
if (readErr instanceof KubernetesAlreadyGoneError)
|
|
1475
|
+
return;
|
|
1476
|
+
throw readErr;
|
|
1477
|
+
}
|
|
1478
|
+
assertSandboxUidStands(target, standing?.metadata?.uid, { cause: err });
|
|
1479
|
+
if (epoch === undefined)
|
|
1480
|
+
throw err;
|
|
1314
1481
|
if (attempt >= HOLDER_EPOCH_WRITE_ATTEMPTS)
|
|
1315
1482
|
throw err;
|
|
1316
|
-
reading =
|
|
1483
|
+
reading = readHolderEpoch(standing?.metadata);
|
|
1317
1484
|
}
|
|
1318
1485
|
}
|
|
1319
1486
|
}
|
|
@@ -1489,7 +1656,7 @@ export async function deleteKubernetesWorkspace(config, workspaceId, options) {
|
|
|
1489
1656
|
// matters most on: a retention job superseded between deciding to delete a
|
|
1490
1657
|
// workspace and calling this would otherwise take the new holder's disk,
|
|
1491
1658
|
// and nothing brings a disk back.
|
|
1492
|
-
await deleteSandboxObject({ client, namespace, name, workspaceId }, 'deleteKubernetesWorkspace', epoch, options?.signal);
|
|
1659
|
+
await deleteSandboxObject({ client, namespace, name, workspaceId, ...expectedUidOf(options) }, 'deleteKubernetesWorkspace', epoch, options?.signal);
|
|
1493
1660
|
}
|
|
1494
1661
|
/**
|
|
1495
1662
|
* Suspend a workspace by id, without ever adopting it: send the patch, then
|
|
@@ -1527,7 +1694,7 @@ export async function suspendKubernetesWorkspace(config, workspaceId, options) {
|
|
|
1527
1694
|
assertNoFlushHere(options?.flush, 'suspendKubernetesWorkspace');
|
|
1528
1695
|
const readiness = resolveKubernetesReadiness(config);
|
|
1529
1696
|
const client = createKubernetesClient(clientAccess(config), clientOptions(config));
|
|
1530
|
-
await writeOperatingMode({ client, namespace, name, workspaceId }, 'suspendKubernetesWorkspace', 'Suspended', epoch, options?.signal);
|
|
1697
|
+
await writeOperatingMode({ client, namespace, name, workspaceId, ...expectedUidOf(options) }, 'suspendKubernetesWorkspace', 'Suspended', epoch, options?.signal);
|
|
1531
1698
|
await awaitPodRetired(client, namespace, name, workspaceId, readiness, options?.signal);
|
|
1532
1699
|
}
|
|
1533
1700
|
/**
|
|
@@ -1568,7 +1735,18 @@ async function openWorkspaceHandle(options) {
|
|
|
1568
1735
|
const { client, namespace, name, workspaceId, readiness } = options;
|
|
1569
1736
|
const id = name;
|
|
1570
1737
|
/** What every fenced write addresses, and what a refusal names. */
|
|
1571
|
-
const target = {
|
|
1738
|
+
const target = {
|
|
1739
|
+
client,
|
|
1740
|
+
namespace,
|
|
1741
|
+
name,
|
|
1742
|
+
workspaceId,
|
|
1743
|
+
// A getter, because the uid is learned at the first bind and every
|
|
1744
|
+
// write after it must carry it. Before that the handle has no uid to
|
|
1745
|
+
// pin, which is the only window it writes unpinned.
|
|
1746
|
+
get expectedSandboxUid() {
|
|
1747
|
+
return sandboxUid;
|
|
1748
|
+
},
|
|
1749
|
+
};
|
|
1572
1750
|
/**
|
|
1573
1751
|
* The epoch this handle writes under when a call passes none.
|
|
1574
1752
|
*
|
|
@@ -2744,6 +2922,79 @@ async function openWorkspaceHandle(options) {
|
|
|
2744
2922
|
await awaitPodRetired(client, namespace, name, workspaceId, readiness, signal);
|
|
2745
2923
|
state = 'suspended';
|
|
2746
2924
|
};
|
|
2925
|
+
/**
|
|
2926
|
+
* Per-sandbox egress on this handle — present exactly when
|
|
2927
|
+
* `config.egress.perSandbox` is configured, which is what makes
|
|
2928
|
+
* `setNetworkPolicy` PRESENT here and absent otherwise (presence follows
|
|
2929
|
+
* configuration, never a probe).
|
|
2930
|
+
*
|
|
2931
|
+
* The fence is replaced, not reused, on every resume: its memo is "proved
|
|
2932
|
+
* once", and a resume is precisely the moment that is no longer enough.
|
|
2933
|
+
* The setter delegates to whichever one is current. Setters are cached per
|
|
2934
|
+
* Sandbox uid, because a policy is NAMED after the uid and its write queue
|
|
2935
|
+
* has to outlive one call.
|
|
2936
|
+
*/
|
|
2937
|
+
const perSandboxEgress = options.perSandboxEgress;
|
|
2938
|
+
let currentFence = perSandboxEgress !== undefined
|
|
2939
|
+
? buildAdmissionFence(client, perSandboxEgress.egress.perSandbox, namespace)
|
|
2940
|
+
: undefined;
|
|
2941
|
+
const delegatingFence = {
|
|
2942
|
+
assertApplied: async (signal) => {
|
|
2943
|
+
await currentFence?.assertApplied(signal);
|
|
2944
|
+
},
|
|
2945
|
+
};
|
|
2946
|
+
const policySetters = new Map();
|
|
2947
|
+
/**
|
|
2948
|
+
* The Sandbox that owns every policy this handle writes, as the cluster
|
|
2949
|
+
* reports it NOW. A different uid than the one this handle bound is a
|
|
2950
|
+
* workspace deleted and created again under the same name: refused, the
|
|
2951
|
+
* way every other call on such a handle is, rather than writing a grant
|
|
2952
|
+
* the new disk's owner never consented to.
|
|
2953
|
+
*/
|
|
2954
|
+
const resolvePolicyOwner = async (signal) => {
|
|
2955
|
+
const standing = await readSandboxObject(target, signal);
|
|
2956
|
+
const uid = standing?.metadata?.uid;
|
|
2957
|
+
if (standing === undefined) {
|
|
2958
|
+
throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, undefined);
|
|
2959
|
+
}
|
|
2960
|
+
if (typeof uid !== 'string' || uid === '') {
|
|
2961
|
+
throw new KubernetesOwnerUidMissingError(name, namespace);
|
|
2962
|
+
}
|
|
2963
|
+
if (sandboxUid !== undefined && uid !== sandboxUid) {
|
|
2964
|
+
throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, uid);
|
|
2965
|
+
}
|
|
2966
|
+
return uid;
|
|
2967
|
+
};
|
|
2968
|
+
const setNetworkPolicy = perSandboxEgress === undefined
|
|
2969
|
+
? undefined
|
|
2970
|
+
: async (policy) => {
|
|
2971
|
+
// Serialised with the transitions: a write racing a resume would
|
|
2972
|
+
// run under the fence the resume is about to replace, and one
|
|
2973
|
+
// racing a destroy would name an owner that is going away.
|
|
2974
|
+
await serialise(async () => {
|
|
2975
|
+
if (state === 'deleted')
|
|
2976
|
+
throw new KubernetesSandboxDestroyedError('setNetworkPolicy', name);
|
|
2977
|
+
const uid = await resolvePolicyOwner();
|
|
2978
|
+
let setter = policySetters.get(uid);
|
|
2979
|
+
if (setter === undefined) {
|
|
2980
|
+
setter = buildPerSandboxPolicySetter({
|
|
2981
|
+
client,
|
|
2982
|
+
fence: delegatingFence,
|
|
2983
|
+
namespace,
|
|
2984
|
+
egress: perSandboxEgress.egress,
|
|
2985
|
+
owner: { kind: 'Sandbox', name, uid },
|
|
2986
|
+
selectorValue: name,
|
|
2987
|
+
...(perSandboxEgress.boundary !== undefined
|
|
2988
|
+
? {
|
|
2989
|
+
verifyUnion: (written, signal) => perSandboxEgress.boundary?.verifyUnionWith(perSandboxEgress.podLabels, `the per-sandbox policy ${written.name} written for Sandbox ${name} in namespace ${namespace}`, written, signal) ?? Promise.resolve(),
|
|
2990
|
+
}
|
|
2991
|
+
: {}),
|
|
2992
|
+
});
|
|
2993
|
+
policySetters.set(uid, setter);
|
|
2994
|
+
}
|
|
2995
|
+
await setter(policy);
|
|
2996
|
+
});
|
|
2997
|
+
};
|
|
2747
2998
|
/**
|
|
2748
2999
|
* Wake the workspace if it is asleep, then bring a session up on it.
|
|
2749
3000
|
*
|
|
@@ -2794,6 +3045,31 @@ async function openWorkspaceHandle(options) {
|
|
|
2794
3045
|
// the object says it was built from, including a refresh another
|
|
2795
3046
|
// process applied while this handle slept.
|
|
2796
3047
|
templateRevision = readPodTemplateHash(current?.metadata);
|
|
3048
|
+
// Per-sandbox egress, before ANY patch: a fresh fence, then every
|
|
3049
|
+
// policy this Sandbox does not own dropped — see
|
|
3050
|
+
// `workspace-per-sandbox.ts` for the rule. A fence that does not
|
|
3051
|
+
// verify refuses the resume with the workspace still suspended, and
|
|
3052
|
+
// nothing written; a policy this Sandbox owns is kept.
|
|
3053
|
+
if (perSandboxEgress !== undefined && current !== undefined) {
|
|
3054
|
+
const uid = current.metadata?.uid;
|
|
3055
|
+
if (typeof uid !== 'string' || uid === '') {
|
|
3056
|
+
throw new KubernetesOwnerUidMissingError(name, namespace);
|
|
3057
|
+
}
|
|
3058
|
+
if (sandboxUid !== undefined && uid !== sandboxUid) {
|
|
3059
|
+
throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, uid);
|
|
3060
|
+
}
|
|
3061
|
+
const fence = buildAdmissionFence(client, perSandboxEgress.egress.perSandbox, namespace);
|
|
3062
|
+
await reverifyPerSandboxEgress({
|
|
3063
|
+
client,
|
|
3064
|
+
namespace,
|
|
3065
|
+
sandboxName: name,
|
|
3066
|
+
ownerUid: uid,
|
|
3067
|
+
egress: perSandboxEgress.egress,
|
|
3068
|
+
fence,
|
|
3069
|
+
...(signal !== undefined ? { signal } : {}),
|
|
3070
|
+
});
|
|
3071
|
+
currentFence = fence;
|
|
3072
|
+
}
|
|
2797
3073
|
if (woke && refreshPodTemplate) {
|
|
2798
3074
|
// The template is re-read HERE rather than remembered from the
|
|
2799
3075
|
// open: the whole point of the option is to pick up an edit made
|
|
@@ -3265,7 +3541,109 @@ async function openWorkspaceHandle(options) {
|
|
|
3265
3541
|
* the transition queue — the public verb takes it, and `suspendNow` runs
|
|
3266
3542
|
* inside it.
|
|
3267
3543
|
*/
|
|
3268
|
-
const quiesceNow = async (quiesceOptions) => await admitted('quiesce', async (handle) => await admittedTransport('quiesce', handle).quiesce(
|
|
3544
|
+
const quiesceNow = async (quiesceOptions) => await admitted('quiesce', async (handle) => await admittedTransport('quiesce', handle).quiesce({
|
|
3545
|
+
...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
|
|
3546
|
+
...(quiesceOptions?.onlyIfIdle === true ? { idleOnly: true } : {}),
|
|
3547
|
+
}, quiesceOptions?.signal));
|
|
3548
|
+
/**
|
|
3549
|
+
* The pinned view over this handle — see {@link KubernetesWorkspace.pinGuest}.
|
|
3550
|
+
*
|
|
3551
|
+
* It owns a sibling transport (built by `pinnedTo`) for the live session's
|
|
3552
|
+
* pod and a Sandbox around it, made on first use and reused while the live
|
|
3553
|
+
* session's transport is the one it was built from. The sibling has no
|
|
3554
|
+
* `refreshHandle`, so nothing it does can follow a replaced pod, and the
|
|
3555
|
+
* Sandbox's `release` does nothing and its unconfirmed-cancellation hook
|
|
3556
|
+
* keeps the pod: a view is not allowed to retire what it does not own.
|
|
3557
|
+
*/
|
|
3558
|
+
const buildPinnedGuest = (expected) => {
|
|
3559
|
+
let built;
|
|
3560
|
+
const refuse = (reason, actual, field, fresh, cause) => new KubernetesGuestPinRefusedError(reason, workspaceId, expected, actual, field, fresh, cause !== undefined ? { cause } : undefined);
|
|
3561
|
+
/** Compared before anything is dialed. The guest's own check is the authority; this one is free. */
|
|
3562
|
+
const resolve = (operation) => {
|
|
3563
|
+
const actual = identityNow();
|
|
3564
|
+
if (state !== 'running' || session === undefined || actual.podUid === undefined) {
|
|
3565
|
+
throw refuse('identity-changed', actual, 'podUid');
|
|
3566
|
+
}
|
|
3567
|
+
if (actual.sandboxUid !== expected.sandboxUid) {
|
|
3568
|
+
throw refuse('identity-changed', actual, 'sandboxUid');
|
|
3569
|
+
}
|
|
3570
|
+
if (actual.podUid !== expected.podUid)
|
|
3571
|
+
throw refuse('identity-changed', actual, 'podUid');
|
|
3572
|
+
// Not yet heard from is not "changed": the guest compares it itself.
|
|
3573
|
+
if (actual.guestBootId !== undefined && actual.guestBootId !== expected.guestBootId) {
|
|
3574
|
+
throw refuse('identity-changed', actual, 'guestBootId');
|
|
3575
|
+
}
|
|
3576
|
+
for (const [claim, uid] of Object.entries(expected.volumeClaimUids ?? {})) {
|
|
3577
|
+
if (actual.volumeClaimUids[claim] !== uid) {
|
|
3578
|
+
throw refuse('identity-changed', actual, 'volumeClaimUids');
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
const parent = admittedTransport(operation, session);
|
|
3582
|
+
if (built === undefined || built.parent !== parent) {
|
|
3583
|
+
const transport = parent.pinnedTo(expected.guestBootId);
|
|
3584
|
+
built = {
|
|
3585
|
+
parent,
|
|
3586
|
+
transport,
|
|
3587
|
+
sandbox: buildKubernetesSandbox({
|
|
3588
|
+
name,
|
|
3589
|
+
rootDir: options.rootDir,
|
|
3590
|
+
transport,
|
|
3591
|
+
release: async () => { },
|
|
3592
|
+
onUnconfirmedCancellation: async () => ({
|
|
3593
|
+
accepted: false,
|
|
3594
|
+
reason: 'pinned-view-keeps-the-pod',
|
|
3595
|
+
}),
|
|
3596
|
+
}),
|
|
3597
|
+
};
|
|
3598
|
+
}
|
|
3599
|
+
return built;
|
|
3600
|
+
};
|
|
3601
|
+
const translate = (err) => {
|
|
3602
|
+
if (err instanceof KubernetesGuestPinRefusedError)
|
|
3603
|
+
return err;
|
|
3604
|
+
const replaced = guestMismatchOf(err);
|
|
3605
|
+
if (replaced !== undefined) {
|
|
3606
|
+
return refuse('guest-replaced', undefined, undefined, replaced.guestBootId, err);
|
|
3607
|
+
}
|
|
3608
|
+
if (isUnauthorizedRefusal(err))
|
|
3609
|
+
return refuse('pod-replaced', undefined, undefined, undefined, err);
|
|
3610
|
+
return err;
|
|
3611
|
+
};
|
|
3612
|
+
const pinned = async (operation, run, signal) => {
|
|
3613
|
+
const view = resolve(operation);
|
|
3614
|
+
try {
|
|
3615
|
+
if (!(await view.transport.supportsPinnedGuest(signal))) {
|
|
3616
|
+
throw refuse('guest-unsupported', undefined, undefined);
|
|
3617
|
+
}
|
|
3618
|
+
return await run(view);
|
|
3619
|
+
}
|
|
3620
|
+
catch (err) {
|
|
3621
|
+
throw translate(err);
|
|
3622
|
+
}
|
|
3623
|
+
};
|
|
3624
|
+
return Object.freeze({
|
|
3625
|
+
expected,
|
|
3626
|
+
exec: async (command, argv, execOptions) => await pinned('exec', async (view) => await view.sandbox.exec(command, argv, execOptions)),
|
|
3627
|
+
readFile: async (path, readOptions) => await pinned('readFile', async (view) => await view.sandbox.readFile(path, readOptions)),
|
|
3628
|
+
writeFile: async (path, content) => await pinned('writeFile', async (view) => await view.sandbox.writeFile(path, content)),
|
|
3629
|
+
listFiles: async (rootPath) => await pinned('listFiles', async (view) => await view.sandbox.listFiles(rootPath)),
|
|
3630
|
+
openTerminal: async (terminalOptions) => await pinned('openTerminal', async (view) => await view.sandbox.openTerminal(terminalOptions)),
|
|
3631
|
+
openTcpConnection: async (connectOptions) => await pinned('openTcpConnection', async (view) => await view.sandbox.openTcpConnection(connectOptions)),
|
|
3632
|
+
quiesce: async (quiesceOptions) => {
|
|
3633
|
+
// Copied before any await, like the workspace's own.
|
|
3634
|
+
if (quiesceOptions?.onlyIfIdle !== undefined &&
|
|
3635
|
+
typeof quiesceOptions.onlyIfIdle !== 'boolean') {
|
|
3636
|
+
throw new TypeError('kubernetes: quiesce({ onlyIfIdle }) must be a boolean');
|
|
3637
|
+
}
|
|
3638
|
+
const request = {
|
|
3639
|
+
...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
|
|
3640
|
+
...(quiesceOptions?.onlyIfIdle === true ? { idleOnly: true } : {}),
|
|
3641
|
+
};
|
|
3642
|
+
const signal = quiesceOptions?.signal;
|
|
3643
|
+
return await pinned('quiesce', async (view) => await view.transport.quiesce(request, signal), signal);
|
|
3644
|
+
},
|
|
3645
|
+
});
|
|
3646
|
+
};
|
|
3269
3647
|
/**
|
|
3270
3648
|
* Tell the host about a gap a suspend went ahead over.
|
|
3271
3649
|
*
|
|
@@ -3489,6 +3867,9 @@ async function openWorkspaceHandle(options) {
|
|
|
3489
3867
|
get identity() {
|
|
3490
3868
|
return identityNow();
|
|
3491
3869
|
},
|
|
3870
|
+
pinGuest(input) {
|
|
3871
|
+
return buildPinnedGuest(freezePinnedIdentity(input));
|
|
3872
|
+
},
|
|
3492
3873
|
onGuestRestart(listener) {
|
|
3493
3874
|
restartListeners.add(listener);
|
|
3494
3875
|
return () => {
|
|
@@ -3512,6 +3893,7 @@ async function openWorkspaceHandle(options) {
|
|
|
3512
3893
|
},
|
|
3513
3894
|
rootDir: options.rootDir,
|
|
3514
3895
|
environment,
|
|
3896
|
+
...(setNetworkPolicy !== undefined ? { setNetworkPolicy } : {}),
|
|
3515
3897
|
async exec(command, argv, execOptions) {
|
|
3516
3898
|
// The default path, untouched: the SDK's exec through the inner
|
|
3517
3899
|
// handle, the shared execution controller, and the retirement
|
|
@@ -3652,7 +4034,23 @@ async function openWorkspaceHandle(options) {
|
|
|
3652
4034
|
// racing the suspend that follows it would be a quiesce of a pod the
|
|
3653
4035
|
// patch has already taken away, and one racing a resume would ask
|
|
3654
4036
|
// the old pod to stop the new one's processes.
|
|
3655
|
-
|
|
4037
|
+
//
|
|
4038
|
+
// The options are copied HERE, synchronously, before the queue is
|
|
4039
|
+
// awaited: a caller that mutates its object after calling cannot
|
|
4040
|
+
// change what is sent, in particular cannot turn an idle-only
|
|
4041
|
+
// request into a stopping one.
|
|
4042
|
+
if (quiesceOptions?.onlyIfIdle !== undefined &&
|
|
4043
|
+
typeof quiesceOptions.onlyIfIdle !== 'boolean') {
|
|
4044
|
+
throw new TypeError('kubernetes: quiesce({ onlyIfIdle }) must be a boolean');
|
|
4045
|
+
}
|
|
4046
|
+
const request = Object.freeze({
|
|
4047
|
+
...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
|
|
4048
|
+
...(quiesceOptions?.onlyIfIdle !== undefined
|
|
4049
|
+
? { onlyIfIdle: quiesceOptions.onlyIfIdle }
|
|
4050
|
+
: {}),
|
|
4051
|
+
...(quiesceOptions?.signal !== undefined ? { signal: quiesceOptions.signal } : {}),
|
|
4052
|
+
});
|
|
4053
|
+
return await serialise(async () => await quiesceNow(request));
|
|
3656
4054
|
},
|
|
3657
4055
|
async flush(flushOptions) {
|
|
3658
4056
|
// Serialised for the reason `quiesce()` is: a flush racing the
|