@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,17 +159,21 @@
|
|
|
159
159
|
*/
|
|
160
160
|
|
|
161
161
|
import type {
|
|
162
|
+
OpenTerminalOptions,
|
|
162
163
|
Sandbox,
|
|
163
164
|
SandboxDestroyOptions,
|
|
164
165
|
SandboxEnvironment,
|
|
166
|
+
SandboxExecOptions,
|
|
165
167
|
SandboxExecResult,
|
|
166
168
|
SandboxFileEntry,
|
|
167
169
|
SandboxId,
|
|
170
|
+
SandboxNetworkPolicy,
|
|
168
171
|
SandboxReadFileOptions,
|
|
169
172
|
SandboxStatus,
|
|
170
173
|
SandboxTcpConnectOptions,
|
|
171
174
|
SandboxTcpConnection,
|
|
172
175
|
SandboxWalkFilesOptions,
|
|
176
|
+
TerminalSession,
|
|
173
177
|
} from '@namzu/sdk'
|
|
174
178
|
|
|
175
179
|
import { OperationDeadline, OperationDeadlineExpired, runFailureCleanup } from '../readiness.js'
|
|
@@ -177,18 +181,24 @@ import type { SandboxRetirementObservation } from '../remote-execution-controlle
|
|
|
177
181
|
import { RemoteCancellationUnknownError } from '../remote-execution-controller.js'
|
|
178
182
|
import {
|
|
179
183
|
type EgressProfileLabel,
|
|
184
|
+
type KubernetesEgressConfig,
|
|
185
|
+
type KubernetesPerSandboxEgressConfig,
|
|
180
186
|
assertEgressPolicyIsEnforceable,
|
|
181
187
|
assertEgressProfileIsUsable,
|
|
182
|
-
|
|
188
|
+
assertPerSandboxEgressIsUsable,
|
|
183
189
|
composeAdditionalPodLabels,
|
|
184
190
|
egressProfileLabel,
|
|
185
191
|
} from './egress-policy.js'
|
|
186
192
|
import {
|
|
187
193
|
type KubernetesGuestEvidence,
|
|
194
|
+
KubernetesGuestPinRefusedError,
|
|
188
195
|
type KubernetesGuestRestart,
|
|
196
|
+
type KubernetesPinnedGuestIdentity,
|
|
197
|
+
type KubernetesWorkspaceExpectedIdentity,
|
|
189
198
|
KubernetesWorkspaceGuestGoneError,
|
|
190
199
|
type KubernetesWorkspaceIdentity,
|
|
191
200
|
KubernetesWorkspaceReplacedError,
|
|
201
|
+
freezePinnedIdentity,
|
|
192
202
|
} from './identity.js'
|
|
193
203
|
import {
|
|
194
204
|
DEFAULT_AGENT_PORT,
|
|
@@ -196,6 +206,7 @@ import {
|
|
|
196
206
|
type KubernetesAgentAddressMode,
|
|
197
207
|
type KubernetesBackendInternalConfig,
|
|
198
208
|
type KubernetesBoundPod,
|
|
209
|
+
type KubernetesEgressBoundary,
|
|
199
210
|
type KubernetesSandboxBinding,
|
|
200
211
|
ReadinessPollTimeout,
|
|
201
212
|
type SandboxTemplateCopy,
|
|
@@ -226,6 +237,7 @@ import {
|
|
|
226
237
|
import {
|
|
227
238
|
HOLDER_EPOCH_ANNOTATION_KEY,
|
|
228
239
|
type HolderEpochReading,
|
|
240
|
+
type JsonPatchOperation,
|
|
229
241
|
OPERATING_MODE_CHANGED_AT_ANNOTATION_KEY,
|
|
230
242
|
POD_TEMPLATE_HASH_ANNOTATION_KEY,
|
|
231
243
|
type PodResource,
|
|
@@ -245,6 +257,12 @@ import {
|
|
|
245
257
|
sandboxCollectionPath,
|
|
246
258
|
sandboxPath,
|
|
247
259
|
} from './objects.js'
|
|
260
|
+
import {
|
|
261
|
+
KubernetesOwnerUidMissingError,
|
|
262
|
+
type PerSandboxPolicyFence,
|
|
263
|
+
buildAdmissionFence,
|
|
264
|
+
buildPerSandboxPolicySetter,
|
|
265
|
+
} from './per-sandbox-policy.js'
|
|
248
266
|
import {
|
|
249
267
|
KubernetesSandboxDestroyedError,
|
|
250
268
|
type KubernetesSandboxHandle,
|
|
@@ -271,8 +289,15 @@ import {
|
|
|
271
289
|
type KubernetesStartDetachedOptions,
|
|
272
290
|
type KubernetesWorkspaceTerminal,
|
|
273
291
|
flushUnsupportedError,
|
|
292
|
+
guestMismatchOf,
|
|
274
293
|
guestWhenReserved,
|
|
294
|
+
isUnauthorizedRefusal,
|
|
275
295
|
} from './transport.js'
|
|
296
|
+
import {
|
|
297
|
+
perSandboxWorkspaceLabels,
|
|
298
|
+
reverifyPerSandboxEgress,
|
|
299
|
+
withoutPerSandboxLabel,
|
|
300
|
+
} from './workspace-per-sandbox.js'
|
|
276
301
|
|
|
277
302
|
/**
|
|
278
303
|
* Thrown when a workspace's `SandboxTemplate` does not describe a block disk
|
|
@@ -335,7 +360,11 @@ export class KubernetesWorkspaceMismatchError extends Error {
|
|
|
335
360
|
/** The Sandbox found standing under this workspace's name. */
|
|
336
361
|
readonly sandboxName: string,
|
|
337
362
|
/** Which configured field the standing object disagrees with. */
|
|
338
|
-
readonly field:
|
|
363
|
+
readonly field:
|
|
364
|
+
| 'sandboxTemplateName'
|
|
365
|
+
| 'egressProfile'
|
|
366
|
+
| 'perSandboxLabel'
|
|
367
|
+
| 'runtimeClassName',
|
|
339
368
|
/**
|
|
340
369
|
* What the configuration asked for. `key=value` for `egressProfile`,
|
|
341
370
|
* because the KEY is configurable too and the value alone would not
|
|
@@ -487,7 +516,7 @@ export class KubernetesWorkspacePreconditionError extends Error {
|
|
|
487
516
|
* as a decimal string, and an integer because a fractional epoch would
|
|
488
517
|
* round-trip through the annotation as something other than what was passed.
|
|
489
518
|
*/
|
|
490
|
-
function assertHolderEpoch(epoch: number | undefined, where: string): number | undefined {
|
|
519
|
+
export function assertHolderEpoch(epoch: number | undefined, where: string): number | undefined {
|
|
491
520
|
if (epoch === undefined) return undefined
|
|
492
521
|
if (!Number.isSafeInteger(epoch) || epoch < 0) {
|
|
493
522
|
throw new Error(
|
|
@@ -662,6 +691,29 @@ export interface KubernetesWorkspaceTransitionOptions {
|
|
|
662
691
|
* patch a failed start's cleanup sends.
|
|
663
692
|
*/
|
|
664
693
|
readonly epoch?: number
|
|
694
|
+
/**
|
|
695
|
+
* The exact Sandbox this call is about, by `metadata.uid` — what a host
|
|
696
|
+
* retained from {@link KubernetesWorkspace.identity} or
|
|
697
|
+
* {@link inspectKubernetesWorkspace}.
|
|
698
|
+
*
|
|
699
|
+
* The workspace id is a deterministic NAME: if the Sandbox the host meant
|
|
700
|
+
* was deleted and another created under the same name, the name now
|
|
701
|
+
* addresses a stranger's disk. With this set, the JSON patch of
|
|
702
|
+
* {@link suspendKubernetesWorkspace} carries `test /metadata/uid` and the
|
|
703
|
+
* DELETE of {@link deleteKubernetesWorkspace} carries `preconditions.uid`,
|
|
704
|
+
* so the API server refuses the write itself. A different owner rejects
|
|
705
|
+
* with {@link KubernetesWorkspaceReplacedError}, changes nothing and is
|
|
706
|
+
* never retried against the new owner; an object already gone still counts
|
|
707
|
+
* as deleted.
|
|
708
|
+
*
|
|
709
|
+
* Read by the two standalone verbs. A handle's own verbs ignore it and
|
|
710
|
+
* fence every write with the uid they bound, which they do without being
|
|
711
|
+
* asked. Omitted, a standalone call is conditioned on nothing about
|
|
712
|
+
* identity, as before. `volumeClaimUids` is not read here: the claims are
|
|
713
|
+
* owned by the Sandbox, so the uid is the identity a write can be
|
|
714
|
+
* conditioned on — check claims with {@link openKubernetesWorkspace}.
|
|
715
|
+
*/
|
|
716
|
+
readonly expectedIdentity?: KubernetesWorkspaceExpectedIdentity
|
|
665
717
|
/**
|
|
666
718
|
* Write the SandboxTemplate's CURRENT `spec.podTemplate` onto the
|
|
667
719
|
* workspace as it wakes, keeping its disk — see
|
|
@@ -799,6 +851,25 @@ export interface KubernetesQuiesceOptions {
|
|
|
799
851
|
* and still serving.
|
|
800
852
|
*/
|
|
801
853
|
readonly graceMs?: number
|
|
854
|
+
/**
|
|
855
|
+
* Look, and touch nothing. With `true` the call resolves (with a report
|
|
856
|
+
* of `rounds: 0`, `stopped: []`, scope `pid-namespace`) only when the
|
|
857
|
+
* guest confirms its whole PID namespace holds nothing but init and the
|
|
858
|
+
* agent, and otherwise rejects with `KubernetesQuiesceBusyError` naming
|
|
859
|
+
* what was seen. It never signals a process, never marks an execution
|
|
860
|
+
* and never waits, so a busy guest is exactly as it was and the caller
|
|
861
|
+
* can retry later.
|
|
862
|
+
*
|
|
863
|
+
* Needs the guest feature `quiesce-idle`: against an older agent the
|
|
864
|
+
* call rejects with `KubernetesQuiesceUnsupportedError` and sends
|
|
865
|
+
* nothing, because that agent would ignore this field and run the
|
|
866
|
+
* stopping quiesce. Cannot be combined with `graceMs` (a `TypeError`).
|
|
867
|
+
* An agent that cannot see its whole namespace, or cannot classify a
|
|
868
|
+
* `/proc` entry, rejects with `KubernetesQuiesceUnconfirmedError`
|
|
869
|
+
* rather than reporting idle. Captured when the call is made, before
|
|
870
|
+
* any await.
|
|
871
|
+
*/
|
|
872
|
+
readonly onlyIfIdle?: boolean
|
|
802
873
|
readonly signal?: AbortSignal
|
|
803
874
|
}
|
|
804
875
|
|
|
@@ -904,6 +975,23 @@ export type KubernetesWorkspaceOrigin = 'created' | 'adopted-running' | 'resumed
|
|
|
904
975
|
* contract that every other backend would then have to answer for, and no
|
|
905
976
|
* `deleteDisk` field on the shared `SandboxDestroyOptions`.
|
|
906
977
|
*/
|
|
978
|
+
/**
|
|
979
|
+
* What {@link KubernetesWorkspace.pinGuest} returns: the data-plane subset of
|
|
980
|
+
* a workspace, every call authenticated against one guest.
|
|
981
|
+
*/
|
|
982
|
+
export interface KubernetesPinnedGuest {
|
|
983
|
+
/** The identity this view is pinned to, frozen. */
|
|
984
|
+
readonly expected: KubernetesPinnedGuestIdentity
|
|
985
|
+
exec(command: string, argv?: string[], options?: SandboxExecOptions): Promise<SandboxExecResult>
|
|
986
|
+
readFile(path: string, options?: SandboxReadFileOptions): Promise<Buffer>
|
|
987
|
+
writeFile(path: string, content: string | Buffer): Promise<void>
|
|
988
|
+
listFiles(rootPath: string): Promise<readonly SandboxFileEntry[]>
|
|
989
|
+
openTerminal(options: OpenTerminalOptions): Promise<TerminalSession>
|
|
990
|
+
openTcpConnection(options: SandboxTcpConnectOptions): Promise<SandboxTcpConnection>
|
|
991
|
+
/** As {@link KubernetesWorkspace.quiesce}, without the lifecycle queue: it is the guest's, not the workspace's. */
|
|
992
|
+
quiesce(options?: KubernetesQuiesceOptions): Promise<KubernetesQuiesceReport>
|
|
993
|
+
}
|
|
994
|
+
|
|
907
995
|
export interface KubernetesWorkspace extends Sandbox {
|
|
908
996
|
/**
|
|
909
997
|
* How this handle came by its workspace — see
|
|
@@ -1214,8 +1302,38 @@ export interface KubernetesWorkspace extends Sandbox {
|
|
|
1214
1302
|
* `KubernetesQuiesceUnconfirmedError` when a process would not stop (the
|
|
1215
1303
|
* message names its pid), and with `KubernetesQuiesceUnsupportedError`
|
|
1216
1304
|
* against an image whose agent predates the op.
|
|
1305
|
+
*
|
|
1306
|
+
* `{ onlyIfIdle: true }` is the read-only variant: see
|
|
1307
|
+
* {@link KubernetesQuiesceOptions.onlyIfIdle}. It rejects with
|
|
1308
|
+
* `KubernetesQuiesceBusyError` when anything is running.
|
|
1217
1309
|
*/
|
|
1218
1310
|
quiesce(options?: KubernetesQuiesceOptions): Promise<KubernetesQuiesceReport>
|
|
1311
|
+
/**
|
|
1312
|
+
* A non-owning view of this workspace that talks ONLY to the guest the
|
|
1313
|
+
* caller authenticated, and refuses (never rebinds) if it changed.
|
|
1314
|
+
*
|
|
1315
|
+
* `expected` names the Sandbox, the pod and the agent process
|
|
1316
|
+
* ({@link KubernetesPinnedGuestIdentity}); read them off `identity` after
|
|
1317
|
+
* a call the guest answered. A missing or empty field throws
|
|
1318
|
+
* `KubernetesPinIncompleteError` here, synchronously.
|
|
1319
|
+
*
|
|
1320
|
+
* Every call on the view first compares `identity` with `expected` and
|
|
1321
|
+
* refuses with `KubernetesGuestPinRefusedError` (`identity-changed`)
|
|
1322
|
+
* before dialing; then it sends the request inside the guest agent's
|
|
1323
|
+
* `pinned` envelope, which the agent compares with its own boot id BEFORE
|
|
1324
|
+
* dispatch, so a guest that is not the pinned one runs nothing
|
|
1325
|
+
* (`guest-replaced`, carrying the id of the agent that refused). A guest
|
|
1326
|
+
* agent that does not advertise `pinned-guest` refuses the call
|
|
1327
|
+
* (`guest-unsupported`) instead of serving it unpinned. A fenced agent
|
|
1328
|
+
* that IS the pinned one answers `KubernetesAgentRetiringError`, with its
|
|
1329
|
+
* id on `guestBootId`.
|
|
1330
|
+
*
|
|
1331
|
+
* The view owns nothing: it has no `destroy`, `suspend` or `resume`, never
|
|
1332
|
+
* retires the pod, and a later `suspend()` of this workspace makes its
|
|
1333
|
+
* calls refuse. It reads its pinned values once; mutating `expected`
|
|
1334
|
+
* afterwards changes nothing.
|
|
1335
|
+
*/
|
|
1336
|
+
pinGuest(expected: KubernetesPinnedGuestIdentity): KubernetesPinnedGuest
|
|
1219
1337
|
/**
|
|
1220
1338
|
* Put everything this workspace has written onto its disk, now.
|
|
1221
1339
|
*
|
|
@@ -1734,6 +1852,8 @@ export function assertAdoptedWorkspaceMatchesConfig(
|
|
|
1734
1852
|
expected: {
|
|
1735
1853
|
readonly sandboxTemplateName: string
|
|
1736
1854
|
readonly profile?: EgressProfileLabel
|
|
1855
|
+
/** The per-sandbox selector label the configuration stamps, key and value. */
|
|
1856
|
+
readonly perSandboxLabel?: EgressProfileLabel
|
|
1737
1857
|
readonly runtimeClassName?: string
|
|
1738
1858
|
},
|
|
1739
1859
|
): void {
|
|
@@ -1760,6 +1880,19 @@ export function assertAdoptedWorkspaceMatchesConfig(
|
|
|
1760
1880
|
)
|
|
1761
1881
|
}
|
|
1762
1882
|
}
|
|
1883
|
+
const perSandboxLabel = expected.perSandboxLabel
|
|
1884
|
+
if (perSandboxLabel !== undefined) {
|
|
1885
|
+
const carried = podTemplate?.metadata?.labels?.[perSandboxLabel.key]
|
|
1886
|
+
if (carried !== perSandboxLabel.value) {
|
|
1887
|
+
throw new KubernetesWorkspaceMismatchError(
|
|
1888
|
+
sandboxName,
|
|
1889
|
+
'perSandboxLabel',
|
|
1890
|
+
`${perSandboxLabel.key}=${perSandboxLabel.value}`,
|
|
1891
|
+
carried === undefined ? undefined : `${perSandboxLabel.key}=${carried}`,
|
|
1892
|
+
`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.`,
|
|
1893
|
+
)
|
|
1894
|
+
}
|
|
1895
|
+
}
|
|
1763
1896
|
if (expected.runtimeClassName === undefined) return
|
|
1764
1897
|
const declared = podTemplate?.spec?.runtimeClassName
|
|
1765
1898
|
const actual = typeof declared === 'string' ? declared : undefined
|
|
@@ -1824,17 +1957,15 @@ export async function createKubernetesWorkspace(
|
|
|
1824
1957
|
options: KubernetesWorkspaceOptions,
|
|
1825
1958
|
): Promise<KubernetesWorkspace> {
|
|
1826
1959
|
options.signal?.throwIfAborted()
|
|
1827
|
-
// `config.egress.perSandbox` is
|
|
1828
|
-
//
|
|
1829
|
-
//
|
|
1830
|
-
//
|
|
1831
|
-
//
|
|
1832
|
-
//
|
|
1833
|
-
//
|
|
1834
|
-
//
|
|
1835
|
-
|
|
1836
|
-
// check a config this path serves no purpose for and report it as usable.
|
|
1837
|
-
assertWorkspaceCarriesNoPerSandboxEgress(config.egress)
|
|
1960
|
+
// `config.egress.perSandbox` is honoured here exactly as on a task handle:
|
|
1961
|
+
// the workspace's pod template carries the per-sandbox selector label, the
|
|
1962
|
+
// Sandbox owns every policy written for it, and the handle serves
|
|
1963
|
+
// `setNetworkPolicy`. A value the backend can never honour — an engine
|
|
1964
|
+
// with no hostname concept, an unnamed fence, a selector key the API
|
|
1965
|
+
// server would refuse — is decidable from config alone and is refused
|
|
1966
|
+
// first, before readiness, before the client, before any request. With
|
|
1967
|
+
// `perSandbox` unset this is a no-op and nothing below changes.
|
|
1968
|
+
assertPerSandboxEgressIsUsable(config.egress)
|
|
1838
1969
|
const readiness = resolveKubernetesReadiness(config)
|
|
1839
1970
|
const namespace = config.namespace
|
|
1840
1971
|
const name = workspaceSandboxName(options.workspaceId)
|
|
@@ -1889,6 +2020,19 @@ export async function createKubernetesWorkspace(
|
|
|
1889
2020
|
)
|
|
1890
2021
|
await egressBoundary?.verifyNamedObject(options.signal)
|
|
1891
2022
|
}
|
|
2023
|
+
// Per-sandbox egress: the fence that bounds what this host may write is
|
|
2024
|
+
// proved BEFORE the POST and before any adopt patch, so a workspace is
|
|
2025
|
+
// never opened, woken or created under a configuration whose fence does
|
|
2026
|
+
// not enforce. `undefined` — and not one extra request — without it.
|
|
2027
|
+
const perSandboxEgress =
|
|
2028
|
+
config.egress?.perSandbox !== undefined
|
|
2029
|
+
? { ...config.egress, perSandbox: config.egress.perSandbox }
|
|
2030
|
+
: undefined
|
|
2031
|
+
const openFence =
|
|
2032
|
+
perSandboxEgress !== undefined
|
|
2033
|
+
? buildAdmissionFence(client, perSandboxEgress.perSandbox, namespace)
|
|
2034
|
+
: undefined
|
|
2035
|
+
await openFence?.assertApplied(options.signal)
|
|
1892
2036
|
|
|
1893
2037
|
// Read and validate BEFORE anything is created, so a template that cannot
|
|
1894
2038
|
// carry a workspace fails with nothing to clean up.
|
|
@@ -1921,7 +2065,8 @@ export async function createKubernetesWorkspace(
|
|
|
1921
2065
|
// workspace onto a new profile is `refreshPodTemplate: true`, which
|
|
1922
2066
|
// rewrites `/spec/podTemplate` with exactly these labels.
|
|
1923
2067
|
const profile = egressProfileLabel(config.egress)
|
|
1924
|
-
const
|
|
2068
|
+
const perSandboxLabels = perSandboxWorkspaceLabels(config.egress, name)
|
|
2069
|
+
const profilePodLabels = composeAdditionalPodLabels(config.egress, perSandboxLabels)
|
|
1925
2070
|
const workspacePodLabels = sandboxPodLabels(template, templateName, profilePodLabels)
|
|
1926
2071
|
const workspaceSubject = `to open workspace ${options.workspaceId} as Sandbox ${name} in namespace ${namespace}`
|
|
1927
2072
|
const verifyIngress = buildIngressVerifier(client, config)
|
|
@@ -1935,7 +2080,14 @@ export async function createKubernetesWorkspace(
|
|
|
1935
2080
|
// reason the ingress check is, and on the adopt path before any resume
|
|
1936
2081
|
// patch — a workspace whose egress stopped being bounded is refused asleep
|
|
1937
2082
|
// rather than woken up to be refused.
|
|
1938
|
-
|
|
2083
|
+
// Without the per-sandbox label when one is configured: the only policy
|
|
2084
|
+
// that selects a pod BY that key and value is this workspace's own, which
|
|
2085
|
+
// a standing workspace already has and which the post-write union check
|
|
2086
|
+
// (against the full labels) judged when it was written. Judged here
|
|
2087
|
+
// against the BASELINE it would read as a widening and refuse to reopen
|
|
2088
|
+
// every workspace that holds a grant.
|
|
2089
|
+
const unionLabels = withoutPerSandboxLabel(workspacePodLabels, perSandboxLabels)
|
|
2090
|
+
await egressBoundary?.verifyUnion(unionLabels, workspaceSubject, options.signal)
|
|
1939
2091
|
|
|
1940
2092
|
// The pod template this call would write, and its revision — built once,
|
|
1941
2093
|
// from the same overlays `buildSandboxBody` applies below, because the
|
|
@@ -1952,8 +2104,9 @@ export async function createKubernetesWorkspace(
|
|
|
1952
2104
|
)
|
|
1953
2105
|
|
|
1954
2106
|
let adopted: AdoptedWorkspace | undefined
|
|
2107
|
+
let createdUid: string | undefined
|
|
1955
2108
|
try {
|
|
1956
|
-
await client.request(
|
|
2109
|
+
const created = await client.request<SandboxResource>(
|
|
1957
2110
|
'POST',
|
|
1958
2111
|
sandboxCollectionPath(namespace),
|
|
1959
2112
|
// No shutdownTime: a workspace carries no expiry — see the module
|
|
@@ -1981,6 +2134,7 @@ export async function createKubernetesWorkspace(
|
|
|
1981
2134
|
}),
|
|
1982
2135
|
options.signal,
|
|
1983
2136
|
)
|
|
2137
|
+
createdUid = created?.metadata?.uid
|
|
1984
2138
|
} catch (err) {
|
|
1985
2139
|
if (!(err instanceof KubernetesConflictError)) throw err
|
|
1986
2140
|
adopted = await adoptExistingWorkspace(
|
|
@@ -1994,6 +2148,14 @@ export async function createKubernetesWorkspace(
|
|
|
1994
2148
|
// were built from, so the object is checked against exactly
|
|
1995
2149
|
// the label this call would have stamped.
|
|
1996
2150
|
...(profile !== undefined ? { profile } : {}),
|
|
2151
|
+
...(perSandboxLabels !== undefined
|
|
2152
|
+
? {
|
|
2153
|
+
perSandboxLabel: {
|
|
2154
|
+
key: Object.keys(perSandboxLabels)[0] as string,
|
|
2155
|
+
value: name,
|
|
2156
|
+
},
|
|
2157
|
+
}
|
|
2158
|
+
: {}),
|
|
1997
2159
|
...(config.runtimeClassName !== undefined
|
|
1998
2160
|
? { runtimeClassName: config.runtimeClassName }
|
|
1999
2161
|
: {}),
|
|
@@ -2001,8 +2163,43 @@ export async function createKubernetesWorkspace(
|
|
|
2001
2163
|
epoch,
|
|
2002
2164
|
options.refreshPodTemplate === true ? refresh : undefined,
|
|
2003
2165
|
options.signal,
|
|
2166
|
+
perSandboxEgress !== undefined && openFence !== undefined
|
|
2167
|
+
? async (uid) => {
|
|
2168
|
+
await reverifyPerSandboxEgress({
|
|
2169
|
+
client,
|
|
2170
|
+
namespace,
|
|
2171
|
+
sandboxName: name,
|
|
2172
|
+
ownerUid: uid,
|
|
2173
|
+
egress: perSandboxEgress,
|
|
2174
|
+
fence: openFence,
|
|
2175
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
2176
|
+
})
|
|
2177
|
+
}
|
|
2178
|
+
: undefined,
|
|
2004
2179
|
)
|
|
2005
2180
|
}
|
|
2181
|
+
// A workspace this call just created has no policy of its own yet, but a
|
|
2182
|
+
// same-named one deleted a moment ago may have left one the garbage
|
|
2183
|
+
// collector has not reached: it selects this label value, so it is
|
|
2184
|
+
// dropped now that the new uid is known. A reply with no uid skips it, and
|
|
2185
|
+
// the handle refuses `setNetworkPolicy` for the same reason.
|
|
2186
|
+
if (
|
|
2187
|
+
adopted === undefined &&
|
|
2188
|
+
perSandboxEgress !== undefined &&
|
|
2189
|
+
openFence !== undefined &&
|
|
2190
|
+
typeof createdUid === 'string' &&
|
|
2191
|
+
createdUid !== ''
|
|
2192
|
+
) {
|
|
2193
|
+
await reverifyPerSandboxEgress({
|
|
2194
|
+
client,
|
|
2195
|
+
namespace,
|
|
2196
|
+
sandboxName: name,
|
|
2197
|
+
ownerUid: createdUid,
|
|
2198
|
+
egress: perSandboxEgress,
|
|
2199
|
+
fence: openFence,
|
|
2200
|
+
...(options.signal !== undefined ? { signal: options.signal } : {}),
|
|
2201
|
+
})
|
|
2202
|
+
}
|
|
2006
2203
|
|
|
2007
2204
|
return await openWorkspaceHandle({
|
|
2008
2205
|
client,
|
|
@@ -2023,6 +2220,15 @@ export async function createKubernetesWorkspace(
|
|
|
2023
2220
|
// would patch the egress profile off the very pod the configured
|
|
2024
2221
|
// policy selects.
|
|
2025
2222
|
...(Object.keys(profilePodLabels).length > 0 ? { podLabels: profilePodLabels } : {}),
|
|
2223
|
+
...(perSandboxEgress !== undefined
|
|
2224
|
+
? {
|
|
2225
|
+
perSandboxEgress: {
|
|
2226
|
+
egress: perSandboxEgress,
|
|
2227
|
+
boundary: egressBoundary,
|
|
2228
|
+
podLabels: workspacePodLabels,
|
|
2229
|
+
},
|
|
2230
|
+
}
|
|
2231
|
+
: {}),
|
|
2026
2232
|
// A create wrote the revision it just computed; an adopt reports
|
|
2027
2233
|
// whatever the standing object carries, which is `undefined` for one
|
|
2028
2234
|
// created before the annotation existed.
|
|
@@ -2086,11 +2292,13 @@ async function adoptExistingWorkspace(
|
|
|
2086
2292
|
expected: {
|
|
2087
2293
|
readonly sandboxTemplateName: string
|
|
2088
2294
|
readonly profile?: EgressProfileLabel
|
|
2295
|
+
readonly perSandboxLabel?: EgressProfileLabel
|
|
2089
2296
|
readonly runtimeClassName?: string
|
|
2090
2297
|
},
|
|
2091
2298
|
epoch: number | undefined,
|
|
2092
2299
|
refresh: PodTemplateRefresh | undefined,
|
|
2093
2300
|
signal?: AbortSignal,
|
|
2301
|
+
reverifyPerSandbox?: (ownerUid: string) => Promise<void>,
|
|
2094
2302
|
): Promise<AdoptedWorkspace> {
|
|
2095
2303
|
const target: WorkspaceWriteTarget = { client, namespace, name, workspaceId }
|
|
2096
2304
|
const existing = await readSandboxObject(target, signal)
|
|
@@ -2120,6 +2328,9 @@ async function adoptExistingWorkspace(
|
|
|
2120
2328
|
? {}
|
|
2121
2329
|
: {
|
|
2122
2330
|
...(expected.profile !== undefined ? { profile: expected.profile } : {}),
|
|
2331
|
+
...(expected.perSandboxLabel !== undefined
|
|
2332
|
+
? { perSandboxLabel: expected.perSandboxLabel }
|
|
2333
|
+
: {}),
|
|
2123
2334
|
...(expected.runtimeClassName !== undefined
|
|
2124
2335
|
? { runtimeClassName: expected.runtimeClassName }
|
|
2125
2336
|
: {}),
|
|
@@ -2131,6 +2342,17 @@ async function adoptExistingWorkspace(
|
|
|
2131
2342
|
// superseded opener patches nothing and starts no pod.
|
|
2132
2343
|
if (epoch !== undefined)
|
|
2133
2344
|
assertHolderEpochAllows(target, 'createKubernetesWorkspace', epoch, reading)
|
|
2345
|
+
// Per-sandbox egress, before any patch for the reason the refusals above
|
|
2346
|
+
// are: the fence is proved again and every policy this Sandbox does not
|
|
2347
|
+
// own is dropped, so a workspace is never woken under a grant it did not
|
|
2348
|
+
// consent to. See `workspace-per-sandbox.ts` for the rule.
|
|
2349
|
+
if (reverifyPerSandbox !== undefined) {
|
|
2350
|
+
const uid = existing?.metadata?.uid
|
|
2351
|
+
if (typeof uid !== 'string' || uid === '') {
|
|
2352
|
+
throw new KubernetesOwnerUidMissingError(name, namespace)
|
|
2353
|
+
}
|
|
2354
|
+
await reverifyPerSandbox(uid)
|
|
2355
|
+
}
|
|
2134
2356
|
if (refreshing && refresh !== undefined) {
|
|
2135
2357
|
// Both BEFORE the patch, and both against the SANDBOX's
|
|
2136
2358
|
// volumeClaimTemplates rather than the template's: those are
|
|
@@ -2337,6 +2559,66 @@ interface WorkspaceWriteTarget {
|
|
|
2337
2559
|
readonly namespace: string
|
|
2338
2560
|
readonly name: string
|
|
2339
2561
|
readonly workspaceId: string
|
|
2562
|
+
/**
|
|
2563
|
+
* The Sandbox `metadata.uid` every write to this target must land on, when
|
|
2564
|
+
* the caller knows one: a handle's is the uid it bound, the standalone
|
|
2565
|
+
* verbs' is `expectedIdentity.sandboxUid`.
|
|
2566
|
+
*
|
|
2567
|
+
* The workspace NAME is deterministic, so it identifies whatever Sandbox
|
|
2568
|
+
* stands under it now — including a stranger that replaced the one the
|
|
2569
|
+
* caller meant. With a uid here, a patch carries `test /metadata/uid` and
|
|
2570
|
+
* a DELETE carries `preconditions.uid`, so the API server refuses the
|
|
2571
|
+
* write itself rather than this process hoping its last read is current.
|
|
2572
|
+
* Read through a getter by a handle, because the handle learns its uid at
|
|
2573
|
+
* its first bind and not before; absent, a write is conditioned on
|
|
2574
|
+
* nothing about identity, exactly as it always was.
|
|
2575
|
+
*/
|
|
2576
|
+
readonly expectedSandboxUid?: string
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
/** The `expectedSandboxUid` a standalone verb's options carry, validated. */
|
|
2580
|
+
function expectedUidOf(options?: KubernetesWorkspaceTransitionOptions): {
|
|
2581
|
+
readonly expectedSandboxUid?: string
|
|
2582
|
+
} {
|
|
2583
|
+
const identity = options?.expectedIdentity
|
|
2584
|
+
if (identity === undefined) return {}
|
|
2585
|
+
if (typeof identity.sandboxUid !== 'string' || identity.sandboxUid === '') {
|
|
2586
|
+
throw new TypeError(
|
|
2587
|
+
'kubernetes: expectedIdentity.sandboxUid must be a non-empty string — an empty uid would be a fence that fences nothing.',
|
|
2588
|
+
)
|
|
2589
|
+
}
|
|
2590
|
+
return { expectedSandboxUid: identity.sandboxUid }
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
/**
|
|
2594
|
+
* Refuse a write whose target is not the Sandbox the caller meant.
|
|
2595
|
+
*
|
|
2596
|
+
* Decided off the read the write is conditioned on, so the refusal is raised
|
|
2597
|
+
* BEFORE any request that could mutate. The same uid then rides in the write
|
|
2598
|
+
* itself, which is what makes the check hold against a replacement landing
|
|
2599
|
+
* between this read and that write.
|
|
2600
|
+
*/
|
|
2601
|
+
function assertSandboxUidStands(
|
|
2602
|
+
target: WorkspaceWriteTarget,
|
|
2603
|
+
standing: string | undefined,
|
|
2604
|
+
options?: ErrorOptions,
|
|
2605
|
+
): void {
|
|
2606
|
+
const expected = target.expectedSandboxUid
|
|
2607
|
+
if (expected === undefined || standing === expected) return
|
|
2608
|
+
throw new KubernetesWorkspaceReplacedError(
|
|
2609
|
+
target.workspaceId,
|
|
2610
|
+
target.name,
|
|
2611
|
+
expected,
|
|
2612
|
+
standing,
|
|
2613
|
+
options,
|
|
2614
|
+
)
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
/** The `test` clause that pins a JSON patch to the Sandbox the caller meant. */
|
|
2618
|
+
function sandboxUidTests(target: WorkspaceWriteTarget): readonly JsonPatchOperation[] {
|
|
2619
|
+
return target.expectedSandboxUid === undefined
|
|
2620
|
+
? []
|
|
2621
|
+
: [{ op: 'test', path: '/metadata/uid', value: target.expectedSandboxUid }]
|
|
2340
2622
|
}
|
|
2341
2623
|
|
|
2342
2624
|
/** One GET of the Sandbox, which is where every condition is read from. */
|
|
@@ -2436,17 +2718,24 @@ async function writeOperatingMode(
|
|
|
2436
2718
|
gate?: HolderEpochReading,
|
|
2437
2719
|
): Promise<void> {
|
|
2438
2720
|
const path = sandboxPath(target.namespace, target.name)
|
|
2439
|
-
|
|
2721
|
+
// Nothing to write is nothing to condition: the stamp-only write exists to
|
|
2722
|
+
// move the epoch, and without one it has no mutation in it at all.
|
|
2723
|
+
if (epoch === undefined && mode === undefined) return
|
|
2724
|
+
if (epoch === undefined && target.expectedSandboxUid === undefined) {
|
|
2440
2725
|
if (mode === undefined) return
|
|
2441
2726
|
await target.client.request('PATCH', path, operatingModePatch(mode), signal)
|
|
2442
2727
|
return
|
|
2443
2728
|
}
|
|
2444
2729
|
let reading = gate ?? readHolderEpoch((await readSandboxObject(target, signal))?.metadata)
|
|
2445
2730
|
for (let attempt = 1; ; attempt += 1) {
|
|
2446
|
-
|
|
2731
|
+
// Identity before epoch: a stranger under this name is not this
|
|
2732
|
+
// caller's workspace, whatever epoch it happens to carry.
|
|
2733
|
+
assertSandboxUidStands(target, reading.sandboxUid)
|
|
2734
|
+
if (epoch !== undefined) assertHolderEpochAllows(target, operation, epoch, reading)
|
|
2447
2735
|
const patch = buildHolderEpochPatch({
|
|
2448
2736
|
reading,
|
|
2449
|
-
epoch,
|
|
2737
|
+
...(epoch !== undefined ? { epoch } : {}),
|
|
2738
|
+
tests: sandboxUidTests(target),
|
|
2450
2739
|
...(mode !== undefined
|
|
2451
2740
|
? { operatingMode: mode, operatingModeChangedAt: new Date().toISOString() }
|
|
2452
2741
|
: {}),
|
|
@@ -2458,6 +2747,10 @@ async function writeOperatingMode(
|
|
|
2458
2747
|
if (!(err instanceof KubernetesPatchNotAppliedError)) throw err
|
|
2459
2748
|
if (attempt >= HOLDER_EPOCH_WRITE_ATTEMPTS) throw err
|
|
2460
2749
|
const next = readHolderEpoch((await readSandboxObject(target, signal))?.metadata)
|
|
2750
|
+
// A replacement that landed between the read and the write is what
|
|
2751
|
+
// the uid `test` refused. It is never retried against the new
|
|
2752
|
+
// owner: this is the refusal, not a race to win.
|
|
2753
|
+
assertSandboxUidStands(target, next.sandboxUid)
|
|
2461
2754
|
const unmoved =
|
|
2462
2755
|
next.annotation === reading.annotation &&
|
|
2463
2756
|
next.resourceVersion === reading.resourceVersion &&
|
|
@@ -2477,7 +2770,7 @@ async function writeOperatingMode(
|
|
|
2477
2770
|
* but next to the object it describes is how `templateCurrent` starts
|
|
2478
2771
|
* reporting drift that does not exist.
|
|
2479
2772
|
*/
|
|
2480
|
-
interface PodTemplateRefresh {
|
|
2773
|
+
export interface PodTemplateRefresh {
|
|
2481
2774
|
readonly podTemplate: SandboxPodTemplate
|
|
2482
2775
|
readonly hash: string
|
|
2483
2776
|
/**
|
|
@@ -2501,7 +2794,7 @@ interface PodTemplateRefresh {
|
|
|
2501
2794
|
* every create POSTs, so `templateCurrent` would report drift that no refresh
|
|
2502
2795
|
* could clear.
|
|
2503
2796
|
*/
|
|
2504
|
-
function buildPodTemplateRefresh(
|
|
2797
|
+
export function buildPodTemplateRefresh(
|
|
2505
2798
|
template: SandboxTemplateCopy,
|
|
2506
2799
|
sandboxTemplateName: string,
|
|
2507
2800
|
runtimeClassName?: string,
|
|
@@ -2576,11 +2869,15 @@ async function writeRefreshedPodTemplate(
|
|
|
2576
2869
|
const path = sandboxPath(target.namespace, target.name)
|
|
2577
2870
|
let reading = gate
|
|
2578
2871
|
for (let attempt = 1; ; attempt += 1) {
|
|
2872
|
+
assertSandboxUidStands(target, reading.sandboxUid)
|
|
2579
2873
|
if (epoch !== undefined) assertHolderEpochAllows(target, operation, epoch, reading)
|
|
2580
2874
|
const patch = buildHolderEpochPatch({
|
|
2581
2875
|
reading,
|
|
2582
2876
|
...(epoch !== undefined ? { epoch } : {}),
|
|
2583
|
-
tests: [
|
|
2877
|
+
tests: [
|
|
2878
|
+
...sandboxUidTests(target),
|
|
2879
|
+
{ op: 'test', path: '/spec/operatingMode', value: 'Suspended' },
|
|
2880
|
+
],
|
|
2584
2881
|
annotations: { [POD_TEMPLATE_HASH_ANNOTATION_KEY]: refresh.hash },
|
|
2585
2882
|
podTemplate: refresh.podTemplate,
|
|
2586
2883
|
operatingMode: 'Running',
|
|
@@ -2605,6 +2902,7 @@ async function writeRefreshedPodTemplate(
|
|
|
2605
2902
|
// — the patch did not apply and nothing changed.
|
|
2606
2903
|
if (sandbox === undefined) throw err
|
|
2607
2904
|
const next = readHolderEpoch(sandbox.metadata)
|
|
2905
|
+
assertSandboxUidStands(target, next.sandboxUid)
|
|
2608
2906
|
if (operatingModeOf(sandbox) !== 'Suspended') {
|
|
2609
2907
|
// Somebody resumed it first. If they also took the workspace
|
|
2610
2908
|
// over, this caller hears THAT rather than being handed a pod
|
|
@@ -2646,7 +2944,8 @@ async function deleteSandboxObject(
|
|
|
2646
2944
|
gate?: HolderEpochReading,
|
|
2647
2945
|
): Promise<void> {
|
|
2648
2946
|
const path = sandboxPath(target.namespace, target.name)
|
|
2649
|
-
|
|
2947
|
+
const expectedUid = target.expectedSandboxUid
|
|
2948
|
+
if (epoch === undefined && expectedUid === undefined) {
|
|
2650
2949
|
try {
|
|
2651
2950
|
await target.client.request('DELETE', path, undefined, signal)
|
|
2652
2951
|
} catch (err) {
|
|
@@ -2656,7 +2955,9 @@ async function deleteSandboxObject(
|
|
|
2656
2955
|
}
|
|
2657
2956
|
let reading = gate
|
|
2658
2957
|
for (let attempt = 1; ; attempt += 1) {
|
|
2659
|
-
|
|
2958
|
+
// The epoch needs the object's current version for its precondition;
|
|
2959
|
+
// the uid alone does not, because it is the precondition.
|
|
2960
|
+
if (reading === undefined && epoch !== undefined) {
|
|
2660
2961
|
let sandbox: SandboxResource | undefined
|
|
2661
2962
|
try {
|
|
2662
2963
|
sandbox = await readSandboxObject(target, signal)
|
|
@@ -2666,8 +2967,11 @@ async function deleteSandboxObject(
|
|
|
2666
2967
|
}
|
|
2667
2968
|
reading = readHolderEpoch(sandbox?.metadata)
|
|
2668
2969
|
}
|
|
2669
|
-
|
|
2670
|
-
|
|
2970
|
+
if (reading !== undefined) {
|
|
2971
|
+
assertSandboxUidStands(target, reading.sandboxUid)
|
|
2972
|
+
if (epoch !== undefined) assertHolderEpochAllows(target, operation, epoch, reading)
|
|
2973
|
+
}
|
|
2974
|
+
if (epoch !== undefined && reading?.resourceVersion === undefined) {
|
|
2671
2975
|
// Unreachable against a real API server, which sets it on every
|
|
2672
2976
|
// object it serves — and a bodyless DELETE here would be an
|
|
2673
2977
|
// UNFENCED delete of somebody's disk, which is the one thing this
|
|
@@ -2683,7 +2987,12 @@ async function deleteSandboxObject(
|
|
|
2683
2987
|
{
|
|
2684
2988
|
apiVersion: 'v1',
|
|
2685
2989
|
kind: 'DeleteOptions',
|
|
2686
|
-
preconditions: {
|
|
2990
|
+
preconditions: {
|
|
2991
|
+
...(reading?.resourceVersion !== undefined
|
|
2992
|
+
? { resourceVersion: reading.resourceVersion }
|
|
2993
|
+
: {}),
|
|
2994
|
+
...(expectedUid !== undefined ? { uid: expectedUid } : {}),
|
|
2995
|
+
},
|
|
2687
2996
|
},
|
|
2688
2997
|
signal,
|
|
2689
2998
|
)
|
|
@@ -2691,8 +3000,21 @@ async function deleteSandboxObject(
|
|
|
2691
3000
|
} catch (err) {
|
|
2692
3001
|
if (err instanceof KubernetesAlreadyGoneError) return
|
|
2693
3002
|
if (!(err instanceof KubernetesConflictError)) throw err
|
|
3003
|
+
// A 409 is either the version moving (epoch path: re-read and try
|
|
3004
|
+
// again) or the uid precondition refusing a stranger. Look, and
|
|
3005
|
+
// let the uid decide: a different owner is the refusal and is
|
|
3006
|
+
// never retried against.
|
|
3007
|
+
let standing: SandboxResource | undefined
|
|
3008
|
+
try {
|
|
3009
|
+
standing = await readSandboxObject(target, signal)
|
|
3010
|
+
} catch (readErr) {
|
|
3011
|
+
if (readErr instanceof KubernetesAlreadyGoneError) return
|
|
3012
|
+
throw readErr
|
|
3013
|
+
}
|
|
3014
|
+
assertSandboxUidStands(target, standing?.metadata?.uid, { cause: err })
|
|
3015
|
+
if (epoch === undefined) throw err
|
|
2694
3016
|
if (attempt >= HOLDER_EPOCH_WRITE_ATTEMPTS) throw err
|
|
2695
|
-
reading =
|
|
3017
|
+
reading = readHolderEpoch(standing?.metadata)
|
|
2696
3018
|
}
|
|
2697
3019
|
}
|
|
2698
3020
|
}
|
|
@@ -3017,7 +3339,7 @@ export async function deleteKubernetesWorkspace(
|
|
|
3017
3339
|
// workspace and calling this would otherwise take the new holder's disk,
|
|
3018
3340
|
// and nothing brings a disk back.
|
|
3019
3341
|
await deleteSandboxObject(
|
|
3020
|
-
{ client, namespace, name, workspaceId },
|
|
3342
|
+
{ client, namespace, name, workspaceId, ...expectedUidOf(options) },
|
|
3021
3343
|
'deleteKubernetesWorkspace',
|
|
3022
3344
|
epoch,
|
|
3023
3345
|
options?.signal,
|
|
@@ -3065,7 +3387,7 @@ export async function suspendKubernetesWorkspace(
|
|
|
3065
3387
|
const readiness = resolveKubernetesReadiness(config)
|
|
3066
3388
|
const client = createKubernetesClient(clientAccess(config), clientOptions(config))
|
|
3067
3389
|
await writeOperatingMode(
|
|
3068
|
-
{ client, namespace, name, workspaceId },
|
|
3390
|
+
{ client, namespace, name, workspaceId, ...expectedUidOf(options) },
|
|
3069
3391
|
'suspendKubernetesWorkspace',
|
|
3070
3392
|
'Suspended',
|
|
3071
3393
|
epoch,
|
|
@@ -3115,6 +3437,19 @@ interface WorkspaceHandleOptions {
|
|
|
3115
3437
|
* with nothing on that path to notice.
|
|
3116
3438
|
*/
|
|
3117
3439
|
readonly podLabels?: Readonly<Record<string, string>>
|
|
3440
|
+
/**
|
|
3441
|
+
* Present exactly when `config.egress.perSandbox` is configured: what the
|
|
3442
|
+
* handle needs to serve `setNetworkPolicy` and to re-verify on a resume.
|
|
3443
|
+
* `podLabels` is the FULL label set the pod carries, per-sandbox label
|
|
3444
|
+
* included, which is what the post-write union check selects with.
|
|
3445
|
+
*/
|
|
3446
|
+
readonly perSandboxEgress?: {
|
|
3447
|
+
readonly egress: KubernetesEgressConfig & {
|
|
3448
|
+
readonly perSandbox: KubernetesPerSandboxEgressConfig
|
|
3449
|
+
}
|
|
3450
|
+
readonly boundary: KubernetesEgressBoundary | undefined
|
|
3451
|
+
readonly podLabels: Readonly<Record<string, string>>
|
|
3452
|
+
}
|
|
3118
3453
|
/** The revision the bound object carries — see `templateRevision`. */
|
|
3119
3454
|
readonly templateRevision?: string
|
|
3120
3455
|
/** The revision the template read at open would produce — see `templateCurrent`. */
|
|
@@ -3218,7 +3553,18 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
|
|
|
3218
3553
|
const { client, namespace, name, workspaceId, readiness } = options
|
|
3219
3554
|
const id = name as SandboxId
|
|
3220
3555
|
/** What every fenced write addresses, and what a refusal names. */
|
|
3221
|
-
const target: WorkspaceWriteTarget = {
|
|
3556
|
+
const target: WorkspaceWriteTarget = {
|
|
3557
|
+
client,
|
|
3558
|
+
namespace,
|
|
3559
|
+
name,
|
|
3560
|
+
workspaceId,
|
|
3561
|
+
// A getter, because the uid is learned at the first bind and every
|
|
3562
|
+
// write after it must carry it. Before that the handle has no uid to
|
|
3563
|
+
// pin, which is the only window it writes unpinned.
|
|
3564
|
+
get expectedSandboxUid() {
|
|
3565
|
+
return sandboxUid
|
|
3566
|
+
},
|
|
3567
|
+
}
|
|
3222
3568
|
/**
|
|
3223
3569
|
* The epoch this handle writes under when a call passes none.
|
|
3224
3570
|
*
|
|
@@ -4479,6 +4825,88 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
|
|
|
4479
4825
|
state = 'suspended'
|
|
4480
4826
|
}
|
|
4481
4827
|
|
|
4828
|
+
/**
|
|
4829
|
+
* Per-sandbox egress on this handle — present exactly when
|
|
4830
|
+
* `config.egress.perSandbox` is configured, which is what makes
|
|
4831
|
+
* `setNetworkPolicy` PRESENT here and absent otherwise (presence follows
|
|
4832
|
+
* configuration, never a probe).
|
|
4833
|
+
*
|
|
4834
|
+
* The fence is replaced, not reused, on every resume: its memo is "proved
|
|
4835
|
+
* once", and a resume is precisely the moment that is no longer enough.
|
|
4836
|
+
* The setter delegates to whichever one is current. Setters are cached per
|
|
4837
|
+
* Sandbox uid, because a policy is NAMED after the uid and its write queue
|
|
4838
|
+
* has to outlive one call.
|
|
4839
|
+
*/
|
|
4840
|
+
const perSandboxEgress = options.perSandboxEgress
|
|
4841
|
+
let currentFence: PerSandboxPolicyFence | undefined =
|
|
4842
|
+
perSandboxEgress !== undefined
|
|
4843
|
+
? buildAdmissionFence(client, perSandboxEgress.egress.perSandbox, namespace)
|
|
4844
|
+
: undefined
|
|
4845
|
+
const delegatingFence: PerSandboxPolicyFence = {
|
|
4846
|
+
assertApplied: async (signal) => {
|
|
4847
|
+
await currentFence?.assertApplied(signal)
|
|
4848
|
+
},
|
|
4849
|
+
}
|
|
4850
|
+
const policySetters = new Map<string, (policy: SandboxNetworkPolicy) => Promise<void>>()
|
|
4851
|
+
/**
|
|
4852
|
+
* The Sandbox that owns every policy this handle writes, as the cluster
|
|
4853
|
+
* reports it NOW. A different uid than the one this handle bound is a
|
|
4854
|
+
* workspace deleted and created again under the same name: refused, the
|
|
4855
|
+
* way every other call on such a handle is, rather than writing a grant
|
|
4856
|
+
* the new disk's owner never consented to.
|
|
4857
|
+
*/
|
|
4858
|
+
const resolvePolicyOwner = async (signal?: AbortSignal): Promise<string> => {
|
|
4859
|
+
const standing = await readSandboxObject(target, signal)
|
|
4860
|
+
const uid = standing?.metadata?.uid
|
|
4861
|
+
if (standing === undefined) {
|
|
4862
|
+
throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, undefined)
|
|
4863
|
+
}
|
|
4864
|
+
if (typeof uid !== 'string' || uid === '') {
|
|
4865
|
+
throw new KubernetesOwnerUidMissingError(name, namespace)
|
|
4866
|
+
}
|
|
4867
|
+
if (sandboxUid !== undefined && uid !== sandboxUid) {
|
|
4868
|
+
throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, uid)
|
|
4869
|
+
}
|
|
4870
|
+
return uid
|
|
4871
|
+
}
|
|
4872
|
+
const setNetworkPolicy =
|
|
4873
|
+
perSandboxEgress === undefined
|
|
4874
|
+
? undefined
|
|
4875
|
+
: async (policy: SandboxNetworkPolicy): Promise<void> => {
|
|
4876
|
+
// Serialised with the transitions: a write racing a resume would
|
|
4877
|
+
// run under the fence the resume is about to replace, and one
|
|
4878
|
+
// racing a destroy would name an owner that is going away.
|
|
4879
|
+
await serialise(async () => {
|
|
4880
|
+
if (state === 'deleted')
|
|
4881
|
+
throw new KubernetesSandboxDestroyedError('setNetworkPolicy', name)
|
|
4882
|
+
const uid = await resolvePolicyOwner()
|
|
4883
|
+
let setter = policySetters.get(uid)
|
|
4884
|
+
if (setter === undefined) {
|
|
4885
|
+
setter = buildPerSandboxPolicySetter({
|
|
4886
|
+
client,
|
|
4887
|
+
fence: delegatingFence,
|
|
4888
|
+
namespace,
|
|
4889
|
+
egress: perSandboxEgress.egress,
|
|
4890
|
+
owner: { kind: 'Sandbox', name, uid },
|
|
4891
|
+
selectorValue: name,
|
|
4892
|
+
...(perSandboxEgress.boundary !== undefined
|
|
4893
|
+
? {
|
|
4894
|
+
verifyUnion: (written, signal) =>
|
|
4895
|
+
perSandboxEgress.boundary?.verifyUnionWith(
|
|
4896
|
+
perSandboxEgress.podLabels,
|
|
4897
|
+
`the per-sandbox policy ${written.name} written for Sandbox ${name} in namespace ${namespace}`,
|
|
4898
|
+
written,
|
|
4899
|
+
signal,
|
|
4900
|
+
) ?? Promise.resolve(),
|
|
4901
|
+
}
|
|
4902
|
+
: {}),
|
|
4903
|
+
})
|
|
4904
|
+
policySetters.set(uid, setter)
|
|
4905
|
+
}
|
|
4906
|
+
await setter(policy)
|
|
4907
|
+
})
|
|
4908
|
+
}
|
|
4909
|
+
|
|
4482
4910
|
/**
|
|
4483
4911
|
* Wake the workspace if it is asleep, then bring a session up on it.
|
|
4484
4912
|
*
|
|
@@ -4531,6 +4959,31 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
|
|
|
4531
4959
|
// the object says it was built from, including a refresh another
|
|
4532
4960
|
// process applied while this handle slept.
|
|
4533
4961
|
templateRevision = readPodTemplateHash(current?.metadata)
|
|
4962
|
+
// Per-sandbox egress, before ANY patch: a fresh fence, then every
|
|
4963
|
+
// policy this Sandbox does not own dropped — see
|
|
4964
|
+
// `workspace-per-sandbox.ts` for the rule. A fence that does not
|
|
4965
|
+
// verify refuses the resume with the workspace still suspended, and
|
|
4966
|
+
// nothing written; a policy this Sandbox owns is kept.
|
|
4967
|
+
if (perSandboxEgress !== undefined && current !== undefined) {
|
|
4968
|
+
const uid = current.metadata?.uid
|
|
4969
|
+
if (typeof uid !== 'string' || uid === '') {
|
|
4970
|
+
throw new KubernetesOwnerUidMissingError(name, namespace)
|
|
4971
|
+
}
|
|
4972
|
+
if (sandboxUid !== undefined && uid !== sandboxUid) {
|
|
4973
|
+
throw new KubernetesWorkspaceReplacedError(workspaceId, name, sandboxUid, uid)
|
|
4974
|
+
}
|
|
4975
|
+
const fence = buildAdmissionFence(client, perSandboxEgress.egress.perSandbox, namespace)
|
|
4976
|
+
await reverifyPerSandboxEgress({
|
|
4977
|
+
client,
|
|
4978
|
+
namespace,
|
|
4979
|
+
sandboxName: name,
|
|
4980
|
+
ownerUid: uid,
|
|
4981
|
+
egress: perSandboxEgress.egress,
|
|
4982
|
+
fence,
|
|
4983
|
+
...(signal !== undefined ? { signal } : {}),
|
|
4984
|
+
})
|
|
4985
|
+
currentFence = fence
|
|
4986
|
+
}
|
|
4534
4987
|
if (woke && refreshPodTemplate) {
|
|
4535
4988
|
// The template is re-read HERE rather than remembered from the
|
|
4536
4989
|
// open: the whole point of the option is to pick up an edit made
|
|
@@ -5058,11 +5511,157 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
|
|
|
5058
5511
|
'quiesce',
|
|
5059
5512
|
async (handle) =>
|
|
5060
5513
|
await admittedTransport('quiesce', handle).quiesce(
|
|
5061
|
-
|
|
5514
|
+
{
|
|
5515
|
+
...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
|
|
5516
|
+
...(quiesceOptions?.onlyIfIdle === true ? { idleOnly: true } : {}),
|
|
5517
|
+
},
|
|
5062
5518
|
quiesceOptions?.signal,
|
|
5063
5519
|
),
|
|
5064
5520
|
)
|
|
5065
5521
|
|
|
5522
|
+
/**
|
|
5523
|
+
* The pinned view over this handle — see {@link KubernetesWorkspace.pinGuest}.
|
|
5524
|
+
*
|
|
5525
|
+
* It owns a sibling transport (built by `pinnedTo`) for the live session's
|
|
5526
|
+
* pod and a Sandbox around it, made on first use and reused while the live
|
|
5527
|
+
* session's transport is the one it was built from. The sibling has no
|
|
5528
|
+
* `refreshHandle`, so nothing it does can follow a replaced pod, and the
|
|
5529
|
+
* Sandbox's `release` does nothing and its unconfirmed-cancellation hook
|
|
5530
|
+
* keeps the pod: a view is not allowed to retire what it does not own.
|
|
5531
|
+
*/
|
|
5532
|
+
const buildPinnedGuest = (expected: KubernetesPinnedGuestIdentity): KubernetesPinnedGuest => {
|
|
5533
|
+
let built:
|
|
5534
|
+
| {
|
|
5535
|
+
readonly parent: KubernetesAgentTransport
|
|
5536
|
+
readonly transport: KubernetesAgentTransport
|
|
5537
|
+
readonly sandbox: KubernetesSandboxHandle
|
|
5538
|
+
}
|
|
5539
|
+
| undefined
|
|
5540
|
+
const refuse = (
|
|
5541
|
+
reason: ConstructorParameters<typeof KubernetesGuestPinRefusedError>[0],
|
|
5542
|
+
actual: KubernetesWorkspaceIdentity | undefined,
|
|
5543
|
+
field: ConstructorParameters<typeof KubernetesGuestPinRefusedError>[4],
|
|
5544
|
+
fresh?: string,
|
|
5545
|
+
cause?: unknown,
|
|
5546
|
+
): KubernetesGuestPinRefusedError =>
|
|
5547
|
+
new KubernetesGuestPinRefusedError(
|
|
5548
|
+
reason,
|
|
5549
|
+
workspaceId,
|
|
5550
|
+
expected,
|
|
5551
|
+
actual,
|
|
5552
|
+
field,
|
|
5553
|
+
fresh,
|
|
5554
|
+
cause !== undefined ? { cause } : undefined,
|
|
5555
|
+
)
|
|
5556
|
+
/** Compared before anything is dialed. The guest's own check is the authority; this one is free. */
|
|
5557
|
+
const resolve = (operation: string) => {
|
|
5558
|
+
const actual = identityNow()
|
|
5559
|
+
if (state !== 'running' || session === undefined || actual.podUid === undefined) {
|
|
5560
|
+
throw refuse('identity-changed', actual, 'podUid')
|
|
5561
|
+
}
|
|
5562
|
+
if (actual.sandboxUid !== expected.sandboxUid) {
|
|
5563
|
+
throw refuse('identity-changed', actual, 'sandboxUid')
|
|
5564
|
+
}
|
|
5565
|
+
if (actual.podUid !== expected.podUid) throw refuse('identity-changed', actual, 'podUid')
|
|
5566
|
+
// Not yet heard from is not "changed": the guest compares it itself.
|
|
5567
|
+
if (actual.guestBootId !== undefined && actual.guestBootId !== expected.guestBootId) {
|
|
5568
|
+
throw refuse('identity-changed', actual, 'guestBootId')
|
|
5569
|
+
}
|
|
5570
|
+
for (const [claim, uid] of Object.entries(expected.volumeClaimUids ?? {})) {
|
|
5571
|
+
if (actual.volumeClaimUids[claim] !== uid) {
|
|
5572
|
+
throw refuse('identity-changed', actual, 'volumeClaimUids')
|
|
5573
|
+
}
|
|
5574
|
+
}
|
|
5575
|
+
const parent = admittedTransport(operation, session)
|
|
5576
|
+
if (built === undefined || built.parent !== parent) {
|
|
5577
|
+
const transport = parent.pinnedTo(expected.guestBootId)
|
|
5578
|
+
built = {
|
|
5579
|
+
parent,
|
|
5580
|
+
transport,
|
|
5581
|
+
sandbox: buildKubernetesSandbox({
|
|
5582
|
+
name,
|
|
5583
|
+
rootDir: options.rootDir,
|
|
5584
|
+
transport,
|
|
5585
|
+
release: async () => {},
|
|
5586
|
+
onUnconfirmedCancellation: async () => ({
|
|
5587
|
+
accepted: false,
|
|
5588
|
+
reason: 'pinned-view-keeps-the-pod',
|
|
5589
|
+
}),
|
|
5590
|
+
}),
|
|
5591
|
+
}
|
|
5592
|
+
}
|
|
5593
|
+
return built
|
|
5594
|
+
}
|
|
5595
|
+
const translate = (err: unknown): unknown => {
|
|
5596
|
+
if (err instanceof KubernetesGuestPinRefusedError) return err
|
|
5597
|
+
const replaced = guestMismatchOf(err)
|
|
5598
|
+
if (replaced !== undefined) {
|
|
5599
|
+
return refuse('guest-replaced', undefined, undefined, replaced.guestBootId, err)
|
|
5600
|
+
}
|
|
5601
|
+
if (isUnauthorizedRefusal(err))
|
|
5602
|
+
return refuse('pod-replaced', undefined, undefined, undefined, err)
|
|
5603
|
+
return err
|
|
5604
|
+
}
|
|
5605
|
+
const pinned = async <T>(
|
|
5606
|
+
operation: string,
|
|
5607
|
+
run: (view: {
|
|
5608
|
+
transport: KubernetesAgentTransport
|
|
5609
|
+
sandbox: KubernetesSandboxHandle
|
|
5610
|
+
}) => Promise<T>,
|
|
5611
|
+
signal?: AbortSignal,
|
|
5612
|
+
): Promise<T> => {
|
|
5613
|
+
const view = resolve(operation)
|
|
5614
|
+
try {
|
|
5615
|
+
if (!(await view.transport.supportsPinnedGuest(signal))) {
|
|
5616
|
+
throw refuse('guest-unsupported', undefined, undefined)
|
|
5617
|
+
}
|
|
5618
|
+
return await run(view)
|
|
5619
|
+
} catch (err) {
|
|
5620
|
+
throw translate(err)
|
|
5621
|
+
}
|
|
5622
|
+
}
|
|
5623
|
+
return Object.freeze({
|
|
5624
|
+
expected,
|
|
5625
|
+
exec: async (command: string, argv?: string[], execOptions?: SandboxExecOptions) =>
|
|
5626
|
+
await pinned('exec', async (view) => await view.sandbox.exec(command, argv, execOptions)),
|
|
5627
|
+
readFile: async (path: string, readOptions?: SandboxReadFileOptions) =>
|
|
5628
|
+
await pinned('readFile', async (view) => await view.sandbox.readFile(path, readOptions)),
|
|
5629
|
+
writeFile: async (path: string, content: string | Buffer) =>
|
|
5630
|
+
await pinned('writeFile', async (view) => await view.sandbox.writeFile(path, content)),
|
|
5631
|
+
listFiles: async (rootPath: string) =>
|
|
5632
|
+
await pinned('listFiles', async (view) => await view.sandbox.listFiles(rootPath)),
|
|
5633
|
+
openTerminal: async (terminalOptions: OpenTerminalOptions) =>
|
|
5634
|
+
await pinned(
|
|
5635
|
+
'openTerminal',
|
|
5636
|
+
async (view) => await view.sandbox.openTerminal(terminalOptions),
|
|
5637
|
+
),
|
|
5638
|
+
openTcpConnection: async (connectOptions: SandboxTcpConnectOptions) =>
|
|
5639
|
+
await pinned(
|
|
5640
|
+
'openTcpConnection',
|
|
5641
|
+
async (view) => await view.sandbox.openTcpConnection(connectOptions),
|
|
5642
|
+
),
|
|
5643
|
+
quiesce: async (quiesceOptions?: KubernetesQuiesceOptions) => {
|
|
5644
|
+
// Copied before any await, like the workspace's own.
|
|
5645
|
+
if (
|
|
5646
|
+
quiesceOptions?.onlyIfIdle !== undefined &&
|
|
5647
|
+
typeof quiesceOptions.onlyIfIdle !== 'boolean'
|
|
5648
|
+
) {
|
|
5649
|
+
throw new TypeError('kubernetes: quiesce({ onlyIfIdle }) must be a boolean')
|
|
5650
|
+
}
|
|
5651
|
+
const request = {
|
|
5652
|
+
...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
|
|
5653
|
+
...(quiesceOptions?.onlyIfIdle === true ? { idleOnly: true } : {}),
|
|
5654
|
+
}
|
|
5655
|
+
const signal = quiesceOptions?.signal
|
|
5656
|
+
return await pinned(
|
|
5657
|
+
'quiesce',
|
|
5658
|
+
async (view) => await view.transport.quiesce(request, signal),
|
|
5659
|
+
signal,
|
|
5660
|
+
)
|
|
5661
|
+
},
|
|
5662
|
+
})
|
|
5663
|
+
}
|
|
5664
|
+
|
|
5066
5665
|
/**
|
|
5067
5666
|
* Tell the host about a gap a suspend went ahead over.
|
|
5068
5667
|
*
|
|
@@ -5307,6 +5906,9 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
|
|
|
5307
5906
|
get identity(): KubernetesWorkspaceIdentity {
|
|
5308
5907
|
return identityNow()
|
|
5309
5908
|
},
|
|
5909
|
+
pinGuest(input: KubernetesPinnedGuestIdentity): KubernetesPinnedGuest {
|
|
5910
|
+
return buildPinnedGuest(freezePinnedIdentity(input))
|
|
5911
|
+
},
|
|
5310
5912
|
onGuestRestart(listener: (event: KubernetesGuestRestart) => void): () => void {
|
|
5311
5913
|
restartListeners.add(listener)
|
|
5312
5914
|
return () => {
|
|
@@ -5329,6 +5931,7 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
|
|
|
5329
5931
|
},
|
|
5330
5932
|
rootDir: options.rootDir,
|
|
5331
5933
|
environment,
|
|
5934
|
+
...(setNetworkPolicy !== undefined ? { setNetworkPolicy } : {}),
|
|
5332
5935
|
|
|
5333
5936
|
async exec(
|
|
5334
5937
|
command: string,
|
|
@@ -5568,7 +6171,25 @@ async function openWorkspaceHandle(options: WorkspaceHandleOptions): Promise<Kub
|
|
|
5568
6171
|
// racing the suspend that follows it would be a quiesce of a pod the
|
|
5569
6172
|
// patch has already taken away, and one racing a resume would ask
|
|
5570
6173
|
// the old pod to stop the new one's processes.
|
|
5571
|
-
|
|
6174
|
+
//
|
|
6175
|
+
// The options are copied HERE, synchronously, before the queue is
|
|
6176
|
+
// awaited: a caller that mutates its object after calling cannot
|
|
6177
|
+
// change what is sent, in particular cannot turn an idle-only
|
|
6178
|
+
// request into a stopping one.
|
|
6179
|
+
if (
|
|
6180
|
+
quiesceOptions?.onlyIfIdle !== undefined &&
|
|
6181
|
+
typeof quiesceOptions.onlyIfIdle !== 'boolean'
|
|
6182
|
+
) {
|
|
6183
|
+
throw new TypeError('kubernetes: quiesce({ onlyIfIdle }) must be a boolean')
|
|
6184
|
+
}
|
|
6185
|
+
const request: KubernetesQuiesceOptions = Object.freeze({
|
|
6186
|
+
...(quiesceOptions?.graceMs !== undefined ? { graceMs: quiesceOptions.graceMs } : {}),
|
|
6187
|
+
...(quiesceOptions?.onlyIfIdle !== undefined
|
|
6188
|
+
? { onlyIfIdle: quiesceOptions.onlyIfIdle }
|
|
6189
|
+
: {}),
|
|
6190
|
+
...(quiesceOptions?.signal !== undefined ? { signal: quiesceOptions.signal } : {}),
|
|
6191
|
+
})
|
|
6192
|
+
return await serialise(async () => await quiesceNow(request))
|
|
5572
6193
|
},
|
|
5573
6194
|
|
|
5574
6195
|
async flush(flushOptions?: KubernetesFlushOptions): Promise<KubernetesFlushReport> {
|