diff --git a/.changeset/filesystem-only-snapshots.md b/.changeset/filesystem-only-snapshots.md new file mode 100644 index 0000000000..f5884aca81 --- /dev/null +++ b/.changeset/filesystem-only-snapshots.md @@ -0,0 +1,8 @@ +--- +'e2b': minor +'@e2b/python-sdk': minor +--- + +Add `mode: 'full' | 'filesystem'` (exported as the `SnapshotMode` type) to `createSnapshot` / `create_snapshot`. With `'filesystem'`, only the filesystem is persisted: the snapshot is smaller and faster to take, and sandboxes created from it cold-boot (start fresh from disk) instead of restoring memory. The source sandbox keeps running either way. Omitted, the API default (a full memory snapshot) applies. + +`pause()` and the `lifecycle.onTimeout` / `lifecycle["on_timeout"]` pause object form accept the same `mode`. Their `keepMemory` / `keep_memory` option is deprecated in favor of it (`keepMemory: false` is `mode: 'filesystem'`) and keeps working; passing both is an `InvalidArgumentError` / `InvalidArgumentException`. diff --git a/packages/js-sdk/src/api/schema.gen.ts b/packages/js-sdk/src/api/schema.gen.ts index 5c5668cdd4..cb39bcc138 100644 --- a/packages/js-sdk/src/api/schema.gen.ts +++ b/packages/js-sdk/src/api/schema.gen.ts @@ -789,7 +789,15 @@ export interface paths { 401: components["responses"]["401"]; 403: components["responses"]["403"]; 404: components["responses"]["404"]; - 409: components["responses"]["409"]; + /** @description Secret name conflict or project live-secret limit reached. A quota response has error_code secret_limit_reached and, when reported by the backend, the effective cap in message. */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 429: components["responses"]["429"]; 500: components["responses"]["500"]; 502: components["responses"]["502"]; @@ -1035,6 +1043,7 @@ export interface paths { }; header?: never; path: { + /** @description Identifier of the team, as its UUID or its public project ID (prj_) */ teamID: components["parameters"]["teamID"]; }; cookie?: never; @@ -1087,6 +1096,7 @@ export interface paths { }; header?: never; path: { + /** @description Identifier of the team, as its UUID or its public project ID (prj_) */ teamID: components["parameters"]["teamID"]; }; cookie?: never; @@ -2248,7 +2258,7 @@ export interface components { * @description Error code */ code: number; - /** @description Machine-readable semantic error code. Not a closed set; initial values: sandbox_capacity_unavailable, sandbox_placement_timeout, sandbox_no_compatible_node, sandbox_create_failed, internal_server_error. */ + /** @description Machine-readable semantic error code. Not a closed set; initial values: sandbox_capacity_unavailable, sandbox_placement_timeout, sandbox_no_compatible_node, sandbox_create_failed, internal_server_error, secret_limit_reached. */ error_code?: string; /** @description Error */ message: string; @@ -2712,6 +2722,8 @@ export interface components { duration?: number; }; SandboxSnapshotRequest: { + /** @description Whether to capture a full memory snapshot. When false, only the filesystem is persisted: the snapshot is smaller and faster to take, and sandboxes created from it cold-boot (start fresh from disk) instead of restoring memory, so they begin without the source sandbox's running processes, in-memory state, and open connections. The source sandbox keeps running in both cases. Defaults to true. */ + memory?: boolean; /** @description Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. */ name?: string; }; @@ -2948,7 +2960,7 @@ export interface components { tags?: string[]; /** * @deprecated - * @description Identifier of the team + * @description Identifier of the team, as its UUID or its public project ID (prj_) */ teamID?: string; }; @@ -3197,6 +3209,7 @@ export interface components { paginationNextToken: string; sandboxID: string; secretID: string; + /** @description Identifier of the team, as its UUID or its public project ID (prj_) */ teamID: string; templateID: string; volumeID: string; diff --git a/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts b/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts index 4a15882070..90f70e0da7 100644 --- a/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts +++ b/packages/js-sdk/src/envd/filesystem/filesystem_pb.ts @@ -24,7 +24,7 @@ import type { Message } from '@bufbuild/protobuf' export const file_filesystem_filesystem: GenFile = /*@__PURE__*/ fileDesc( - 'ChtmaWxlc3lzdGVtL2ZpbGVzeXN0ZW0ucHJvdG8SCmZpbGVzeXN0ZW0iMgoLTW92ZVJlcXVlc3QSDgoGc291cmNlGAEgASgJEhMKC2Rlc3RpbmF0aW9uGAIgASgJIjQKDE1vdmVSZXNwb25zZRIkCgVlbnRyeRgBIAEoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvIh4KDk1ha2VEaXJSZXF1ZXN0EgwKBHBhdGgYASABKAkiNwoPTWFrZURpclJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8iHQoNUmVtb3ZlUmVxdWVzdBIMCgRwYXRoGAEgASgJIhAKDlJlbW92ZVJlc3BvbnNlIhsKC1N0YXRSZXF1ZXN0EgwKBHBhdGgYASABKAkiNAoMU3RhdFJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8i5QIKCUVudHJ5SW5mbxIMCgRuYW1lGAEgASgJEiIKBHR5cGUYAiABKA4yFC5maWxlc3lzdGVtLkZpbGVUeXBlEgwKBHBhdGgYAyABKAkSDAoEc2l6ZRgEIAEoAxIMCgRtb2RlGAUgASgNEhMKC3Blcm1pc3Npb25zGAYgASgJEg0KBW93bmVyGAcgASgJEg0KBWdyb3VwGAggASgJEjEKDW1vZGlmaWVkX3RpbWUYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhsKDnN5bWxpbmtfdGFyZ2V0GAogASgJSACIAQESNQoIbWV0YWRhdGEYCyADKAsyIy5maWxlc3lzdGVtLkVudHJ5SW5mby5NZXRhZGF0YUVudHJ5Gi8KDU1ldGFkYXRhRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIRCg9fc3ltbGlua190YXJnZXQiLQoOTGlzdERpclJlcXVlc3QSDAoEcGF0aBgBIAEoCRINCgVkZXB0aBgCIAEoDSI5Cg9MaXN0RGlyUmVzcG9uc2USJgoHZW50cmllcxgBIAMoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvImcKD1dhdGNoRGlyUmVxdWVzdBIMCgRwYXRoGAEgASgJEhEKCXJlY3Vyc2l2ZRgCIAEoCBIVCg1pbmNsdWRlX2VudHJ5GAMgASgIEhwKFGFsbG93X25ldHdvcmtfbW91bnRzGAQgASgIInkKD0ZpbGVzeXN0ZW1FdmVudBIMCgRuYW1lGAEgASgJEiMKBHR5cGUYAiABKA4yFS5maWxlc3lzdGVtLkV2ZW50VHlwZRIpCgVlbnRyeRgDIAEoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvSACIAQFCCAoGX2VudHJ5IuABChBXYXRjaERpclJlc3BvbnNlEjgKBXN0YXJ0GAEgASgLMicuZmlsZXN5c3RlbS5XYXRjaERpclJlc3BvbnNlLlN0YXJ0RXZlbnRIABIxCgpmaWxlc3lzdGVtGAIgASgLMhsuZmlsZXN5c3RlbS5GaWxlc3lzdGVtRXZlbnRIABI7CglrZWVwYWxpdmUYAyABKAsyJi5maWxlc3lzdGVtLldhdGNoRGlyUmVzcG9uc2UuS2VlcEFsaXZlSAAaDAoKU3RhcnRFdmVudBoLCglLZWVwQWxpdmVCBwoFZXZlbnQibAoUQ3JlYXRlV2F0Y2hlclJlcXVlc3QSDAoEcGF0aBgBIAEoCRIRCglyZWN1cnNpdmUYAiABKAgSFQoNaW5jbHVkZV9lbnRyeRgDIAEoCBIcChRhbGxvd19uZXR3b3JrX21vdW50cxgEIAEoCCIrChVDcmVhdGVXYXRjaGVyUmVzcG9uc2USEgoKd2F0Y2hlcl9pZBgBIAEoCSItChdHZXRXYXRjaGVyRXZlbnRzUmVxdWVzdBISCgp3YXRjaGVyX2lkGAEgASgJIkcKGEdldFdhdGNoZXJFdmVudHNSZXNwb25zZRIrCgZldmVudHMYASADKAsyGy5maWxlc3lzdGVtLkZpbGVzeXN0ZW1FdmVudCIqChRSZW1vdmVXYXRjaGVyUmVxdWVzdBISCgp3YXRjaGVyX2lkGAEgASgJIhcKFVJlbW92ZVdhdGNoZXJSZXNwb25zZSppCghGaWxlVHlwZRIZChVGSUxFX1RZUEVfVU5TUEVDSUZJRUQQABISCg5GSUxFX1RZUEVfRklMRRABEhcKE0ZJTEVfVFlQRV9ESVJFQ1RPUlkQAhIVChFGSUxFX1RZUEVfU1lNTElOSxADKpgBCglFdmVudFR5cGUSGgoWRVZFTlRfVFlQRV9VTlNQRUNJRklFRBAAEhUKEUVWRU5UX1RZUEVfQ1JFQVRFEAESFAoQRVZFTlRfVFlQRV9XUklURRACEhUKEUVWRU5UX1RZUEVfUkVNT1ZFEAMSFQoRRVZFTlRfVFlQRV9SRU5BTUUQBBIUChBFVkVOVF9UWVBFX0NITU9EEAUynwUKCkZpbGVzeXN0ZW0SOQoEU3RhdBIXLmZpbGVzeXN0ZW0uU3RhdFJlcXVlc3QaGC5maWxlc3lzdGVtLlN0YXRSZXNwb25zZRJCCgdNYWtlRGlyEhouZmlsZXN5c3RlbS5NYWtlRGlyUmVxdWVzdBobLmZpbGVzeXN0ZW0uTWFrZURpclJlc3BvbnNlEjkKBE1vdmUSFy5maWxlc3lzdGVtLk1vdmVSZXF1ZXN0GhguZmlsZXN5c3RlbS5Nb3ZlUmVzcG9uc2USQgoHTGlzdERpchIaLmZpbGVzeXN0ZW0uTGlzdERpclJlcXVlc3QaGy5maWxlc3lzdGVtLkxpc3REaXJSZXNwb25zZRI/CgZSZW1vdmUSGS5maWxlc3lzdGVtLlJlbW92ZVJlcXVlc3QaGi5maWxlc3lzdGVtLlJlbW92ZVJlc3BvbnNlEkcKCFdhdGNoRGlyEhsuZmlsZXN5c3RlbS5XYXRjaERpclJlcXVlc3QaHC5maWxlc3lzdGVtLldhdGNoRGlyUmVzcG9uc2UwARJUCg1DcmVhdGVXYXRjaGVyEiAuZmlsZXN5c3RlbS5DcmVhdGVXYXRjaGVyUmVxdWVzdBohLmZpbGVzeXN0ZW0uQ3JlYXRlV2F0Y2hlclJlc3BvbnNlEl0KEEdldFdhdGNoZXJFdmVudHMSIy5maWxlc3lzdGVtLkdldFdhdGNoZXJFdmVudHNSZXF1ZXN0GiQuZmlsZXN5c3RlbS5HZXRXYXRjaGVyRXZlbnRzUmVzcG9uc2USVAoNUmVtb3ZlV2F0Y2hlchIgLmZpbGVzeXN0ZW0uUmVtb3ZlV2F0Y2hlclJlcXVlc3QaIS5maWxlc3lzdGVtLlJlbW92ZVdhdGNoZXJSZXNwb25zZUJpCg5jb20uZmlsZXN5c3RlbUIPRmlsZXN5c3RlbVByb3RvUAGiAgNGWFiqAgpGaWxlc3lzdGVtygIKRmlsZXN5c3RlbeICFkZpbGVzeXN0ZW1cR1BCTWV0YWRhdGHqAgpGaWxlc3lzdGVtYgZwcm90bzM', + 'ChtmaWxlc3lzdGVtL2ZpbGVzeXN0ZW0ucHJvdG8SCmZpbGVzeXN0ZW0iMgoLTW92ZVJlcXVlc3QSDgoGc291cmNlGAEgASgJEhMKC2Rlc3RpbmF0aW9uGAIgASgJIjQKDE1vdmVSZXNwb25zZRIkCgVlbnRyeRgBIAEoCzIVLmZpbGVzeXN0ZW0uRW50cnlJbmZvIh4KDk1ha2VEaXJSZXF1ZXN0EgwKBHBhdGgYASABKAkiNwoPTWFrZURpclJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8iHQoNUmVtb3ZlUmVxdWVzdBIMCgRwYXRoGAEgASgJIhAKDlJlbW92ZVJlc3BvbnNlIhsKC1N0YXRSZXF1ZXN0EgwKBHBhdGgYASABKAkiNAoMU3RhdFJlc3BvbnNlEiQKBWVudHJ5GAEgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8i+QIKCUVudHJ5SW5mbxIMCgRuYW1lGAEgASgJEiIKBHR5cGUYAiABKA4yFC5maWxlc3lzdGVtLkZpbGVUeXBlEgwKBHBhdGgYAyABKAkSDAoEc2l6ZRgEIAEoAxIMCgRtb2RlGAUgASgNEhMKC3Blcm1pc3Npb25zGAYgASgJEg0KBW93bmVyGAcgASgJEg0KBWdyb3VwGAggASgJEjEKDW1vZGlmaWVkX3RpbWUYCSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEhsKDnN5bWxpbmtfdGFyZ2V0GAogASgJSACIAQESNQoIbWV0YWRhdGEYCyADKAsyIy5maWxlc3lzdGVtLkVudHJ5SW5mby5NZXRhZGF0YUVudHJ5EhIKCmlzX3N5bWxpbmsYDCABKAgaLwoNTWV0YWRhdGFFbnRyeRILCgNrZXkYASABKAkSDQoFdmFsdWUYAiABKAk6AjgBQhEKD19zeW1saW5rX3RhcmdldCItCg5MaXN0RGlyUmVxdWVzdBIMCgRwYXRoGAEgASgJEg0KBWRlcHRoGAIgASgNIjkKD0xpc3REaXJSZXNwb25zZRImCgdlbnRyaWVzGAEgAygLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm8iZwoPV2F0Y2hEaXJSZXF1ZXN0EgwKBHBhdGgYASABKAkSEQoJcmVjdXJzaXZlGAIgASgIEhUKDWluY2x1ZGVfZW50cnkYAyABKAgSHAoUYWxsb3dfbmV0d29ya19tb3VudHMYBCABKAgieQoPRmlsZXN5c3RlbUV2ZW50EgwKBG5hbWUYASABKAkSIwoEdHlwZRgCIAEoDjIVLmZpbGVzeXN0ZW0uRXZlbnRUeXBlEikKBWVudHJ5GAMgASgLMhUuZmlsZXN5c3RlbS5FbnRyeUluZm9IAIgBAUIICgZfZW50cnki4AEKEFdhdGNoRGlyUmVzcG9uc2USOAoFc3RhcnQYASABKAsyJy5maWxlc3lzdGVtLldhdGNoRGlyUmVzcG9uc2UuU3RhcnRFdmVudEgAEjEKCmZpbGVzeXN0ZW0YAiABKAsyGy5maWxlc3lzdGVtLkZpbGVzeXN0ZW1FdmVudEgAEjsKCWtlZXBhbGl2ZRgDIAEoCzImLmZpbGVzeXN0ZW0uV2F0Y2hEaXJSZXNwb25zZS5LZWVwQWxpdmVIABoMCgpTdGFydEV2ZW50GgsKCUtlZXBBbGl2ZUIHCgVldmVudCJsChRDcmVhdGVXYXRjaGVyUmVxdWVzdBIMCgRwYXRoGAEgASgJEhEKCXJlY3Vyc2l2ZRgCIAEoCBIVCg1pbmNsdWRlX2VudHJ5GAMgASgIEhwKFGFsbG93X25ldHdvcmtfbW91bnRzGAQgASgIIisKFUNyZWF0ZVdhdGNoZXJSZXNwb25zZRISCgp3YXRjaGVyX2lkGAEgASgJIi0KF0dldFdhdGNoZXJFdmVudHNSZXF1ZXN0EhIKCndhdGNoZXJfaWQYASABKAkiRwoYR2V0V2F0Y2hlckV2ZW50c1Jlc3BvbnNlEisKBmV2ZW50cxgBIAMoCzIbLmZpbGVzeXN0ZW0uRmlsZXN5c3RlbUV2ZW50IioKFFJlbW92ZVdhdGNoZXJSZXF1ZXN0EhIKCndhdGNoZXJfaWQYASABKAkiFwoVUmVtb3ZlV2F0Y2hlclJlc3BvbnNlKmkKCEZpbGVUeXBlEhkKFUZJTEVfVFlQRV9VTlNQRUNJRklFRBAAEhIKDkZJTEVfVFlQRV9GSUxFEAESFwoTRklMRV9UWVBFX0RJUkVDVE9SWRACEhUKEUZJTEVfVFlQRV9TWU1MSU5LEAMqmAEKCUV2ZW50VHlwZRIaChZFVkVOVF9UWVBFX1VOU1BFQ0lGSUVEEAASFQoRRVZFTlRfVFlQRV9DUkVBVEUQARIUChBFVkVOVF9UWVBFX1dSSVRFEAISFQoRRVZFTlRfVFlQRV9SRU1PVkUQAxIVChFFVkVOVF9UWVBFX1JFTkFNRRAEEhQKEEVWRU5UX1RZUEVfQ0hNT0QQBTKfBQoKRmlsZXN5c3RlbRI5CgRTdGF0EhcuZmlsZXN5c3RlbS5TdGF0UmVxdWVzdBoYLmZpbGVzeXN0ZW0uU3RhdFJlc3BvbnNlEkIKB01ha2VEaXISGi5maWxlc3lzdGVtLk1ha2VEaXJSZXF1ZXN0GhsuZmlsZXN5c3RlbS5NYWtlRGlyUmVzcG9uc2USOQoETW92ZRIXLmZpbGVzeXN0ZW0uTW92ZVJlcXVlc3QaGC5maWxlc3lzdGVtLk1vdmVSZXNwb25zZRJCCgdMaXN0RGlyEhouZmlsZXN5c3RlbS5MaXN0RGlyUmVxdWVzdBobLmZpbGVzeXN0ZW0uTGlzdERpclJlc3BvbnNlEj8KBlJlbW92ZRIZLmZpbGVzeXN0ZW0uUmVtb3ZlUmVxdWVzdBoaLmZpbGVzeXN0ZW0uUmVtb3ZlUmVzcG9uc2USRwoIV2F0Y2hEaXISGy5maWxlc3lzdGVtLldhdGNoRGlyUmVxdWVzdBocLmZpbGVzeXN0ZW0uV2F0Y2hEaXJSZXNwb25zZTABElQKDUNyZWF0ZVdhdGNoZXISIC5maWxlc3lzdGVtLkNyZWF0ZVdhdGNoZXJSZXF1ZXN0GiEuZmlsZXN5c3RlbS5DcmVhdGVXYXRjaGVyUmVzcG9uc2USXQoQR2V0V2F0Y2hlckV2ZW50cxIjLmZpbGVzeXN0ZW0uR2V0V2F0Y2hlckV2ZW50c1JlcXVlc3QaJC5maWxlc3lzdGVtLkdldFdhdGNoZXJFdmVudHNSZXNwb25zZRJUCg1SZW1vdmVXYXRjaGVyEiAuZmlsZXN5c3RlbS5SZW1vdmVXYXRjaGVyUmVxdWVzdBohLmZpbGVzeXN0ZW0uUmVtb3ZlV2F0Y2hlclJlc3BvbnNlQmkKDmNvbS5maWxlc3lzdGVtQg9GaWxlc3lzdGVtUHJvdG9QAaICA0ZYWKoCCkZpbGVzeXN0ZW3KAgpGaWxlc3lzdGVt4gIWRmlsZXN5c3RlbVxHUEJNZXRhZGF0YeoCCkZpbGVzeXN0ZW1iBnByb3RvMw', [file_google_protobuf_timestamp] ) @@ -236,6 +236,13 @@ export type EntryInfo = Message<'filesystem.EntryInfo'> & { * @generated from field: map metadata = 11; */ metadata: { [key: string]: string } + + /** + * True when the entry itself is a symlink; type then describes the symlink's target. + * + * @generated from field: bool is_symlink = 12; + */ + isSymlink: boolean } /** diff --git a/packages/js-sdk/src/envd/schema.gen.ts b/packages/js-sdk/src/envd/schema.gen.ts index 4890a4ddb5..c00443dc6e 100644 --- a/packages/js-sdk/src/envd/schema.gen.ts +++ b/packages/js-sdk/src/envd/schema.gen.ts @@ -298,18 +298,26 @@ export interface components { mem_cache?: number; /** @description Total virtual memory in bytes */ mem_total?: number; - /** @description Total virtual memory in MiB */ - mem_total_mib?: number; /** @description Used virtual memory in bytes */ mem_used?: number; - /** @description Used virtual memory in MiB */ - mem_used_mib?: number; + /** @description The latest 32 out-of-memory kills since the guest booted, oldest first. Reading does not consume them. Empty when there are none. Omitted when not known (while envd is still reading the kernel log, when it can't be read or envd stopped reading it, and outside Firecracker). */ + oom_kills?: components["schemas"]["OOMKill"][]; /** * Format: int64 * @description Unix timestamp in UTC for current sandbox time */ ts?: number; }; + /** @description A process the guest kernel killed for running out of memory */ + OOMKill: { + /** @description Name of the killed process, or Unknown if the record doesn't show it */ + process: string; + /** + * Format: int64 + * @description Kernel log sequence number of the kill record. It only grows, across pauses and envd restarts. + */ + seq: number; + }; }; responses: { /** @description Entire file downloaded successfully. */ diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index be24301a39..55a74e6af3 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -95,6 +95,7 @@ export type { SnapshotListOpts, SnapshotPaginator, CreateSnapshotOpts, + SnapshotMode, } from './sandbox/sandboxApi' export type { McpServer } from './sandbox/mcp' diff --git a/packages/js-sdk/src/sandbox/filesystem/index.ts b/packages/js-sdk/src/sandbox/filesystem/index.ts index f3bae63862..adb637a3d1 100644 --- a/packages/js-sdk/src/sandbox/filesystem/index.ts +++ b/packages/js-sdk/src/sandbox/filesystem/index.ts @@ -240,7 +240,9 @@ function metadataHeaders( export function mapEntryInfo(entry: FsEntryInfo): EntryInfo { return { name: entry.name, - type: mapFileType(entry.type), + // Newer envd reports a symlink's target type in `type` and marks the link + // itself with `isSymlink`; older envd reports FILE_TYPE_SYMLINK in `type`. + type: entry.isSymlink ? FileType.SYMLINK : mapFileType(entry.type), path: entry.path, size: Number(entry.size), mode: entry.mode, diff --git a/packages/js-sdk/src/sandbox/index.ts b/packages/js-sdk/src/sandbox/index.ts index 778c9859fd..3af8e5e3d4 100644 --- a/packages/js-sdk/src/sandbox/index.ts +++ b/packages/js-sdk/src/sandbox/index.ts @@ -639,11 +639,11 @@ export class Sandbox extends SandboxApi { /** * Pause a sandbox by its ID. * - * @param opts connection options, plus `keepMemory` to control the snapshot - * kind. When `opts.keepMemory` is `false`, the in-memory state is dropped and - * only the filesystem is persisted (a filesystem-only snapshot); resuming such - * a sandbox cold-boots (reboots) it from disk, losing running processes and - * open connections. Defaults to `true` (full memory snapshot). + * @param opts connection options, plus `mode` to control the snapshot kind. + * With `mode: 'filesystem'`, the in-memory state is dropped and only the + * filesystem is persisted; resuming such a sandbox cold-boots (reboots) it + * from disk, losing running processes and open connections. Defaults to + * `'full'` (memory and filesystem). * * @returns `true` if the sandbox got paused, `false` if the sandbox was already paused. * @@ -653,7 +653,7 @@ export class Sandbox extends SandboxApi { * await sandbox.pause() * * // filesystem-only snapshot (resume reboots the sandbox) - * await sandbox.pause({ keepMemory: false }) + * await sandbox.pause({ mode: 'filesystem' }) * ``` */ async pause(opts?: SandboxPauseOpts): Promise { @@ -676,10 +676,19 @@ export class Sandbox extends SandboxApi { * * Use the returned `snapshotId` with `Sandbox.create(snapshotId)` to create a new sandbox from the snapshot. * - * @param opts snapshot creation options including optional name and connection options. + * @param opts snapshot creation options including optional name, `mode` + * (`'filesystem'` takes a filesystem-only snapshot; sandboxes created from it + * cold-boot instead of restoring memory), and connection options. * * @returns snapshot information including the snapshot ID. * + * @throws {@link SandboxError} with `statusCode` 400 when `mode: 'filesystem'` + * is requested but the feature is not enabled for the team + * (`snapshot_filesystem_only_disabled`), and with `statusCode` 409 when the + * sandbox's node runs an orchestrator that predates the option + * (`snapshot_filesystem_only_unsupported_node`). A full memory snapshot + * still works in both cases; for 409, pause and resume the sandbox and retry. + * * @example * ```ts * const sandbox = await Sandbox.create() @@ -690,12 +699,16 @@ export class Sandbox extends SandboxApi { * * // Create a new sandbox from the snapshot * const newSandbox = await Sandbox.create(snapshot.snapshotId) + * + * // Filesystem-only snapshot: sandboxes created from it start fresh from disk + * const fsSnapshot = await sandbox.createSnapshot({ mode: 'filesystem' }) * ``` */ async createSnapshot(opts?: CreateSnapshotOpts): Promise { return await SandboxApi.createSnapshot(this.sandboxId, { ...this.resolveApiOpts(opts), name: opts?.name, + mode: opts?.mode, }) } diff --git a/packages/js-sdk/src/sandbox/sandboxApi.ts b/packages/js-sdk/src/sandbox/sandboxApi.ts index 3b3b822a52..50872b4881 100644 --- a/packages/js-sdk/src/sandbox/sandboxApi.ts +++ b/packages/js-sdk/src/sandbox/sandboxApi.ts @@ -424,14 +424,56 @@ export interface SandboxIamOpts { tokens?: Record } +/** + * What a pause or snapshot persists. + * + * - `'full'`: the filesystem and the memory (running processes, in-memory + * state and open connections); resuming restores the sandbox exactly where + * it left off. + * - `'filesystem'`: only the filesystem. Smaller and faster to take; resuming + * (or creating a sandbox from the snapshot) cold-boots it from disk, without + * the running processes, in-memory state and open connections. + */ +export type SnapshotMode = 'full' | 'filesystem' + +const SNAPSHOT_MODES: readonly SnapshotMode[] = ['full', 'filesystem'] + +/** + * Resolve `mode` (or the deprecated `keepMemory`) into the API's `memory` + * flag. Unset, it stays `undefined` so the API default (currently a full + * snapshot) applies. `field` names the option in error messages. + */ +function snapshotMemory( + field: string, + mode: SnapshotMode | undefined, + keepMemory?: boolean +): boolean | undefined { + // `!= null` also skips an explicit `null` from untyped callers. + if (mode != null && keepMemory != null) { + throw new InvalidArgumentError( + `Pass either ${field} or the deprecated keepMemory, not both.` + ) + } + if (mode == null) { + return keepMemory ?? undefined + } + // Re-check at runtime for callers that bypass the type. + if (!SNAPSHOT_MODES.includes(mode)) { + throw new InvalidArgumentError( + `${field} must be one of: ${SNAPSHOT_MODES.join(', ')} (got ${JSON.stringify(mode)}).` + ) + } + return mode === 'full' +} + /** * What happens when the sandbox timeout is reached. Either the bare action * (`'pause'` / `'kill'`), or an object form that also controls the pause - * snapshot kind via `keepMemory`. + * snapshot kind via `mode`. * - * The object form is a discriminated union on `action`: `keepMemory` is only - * accepted alongside `action: 'pause'`. Passing `keepMemory` with - * `action: 'kill'` is a compile-time type error. + * The object form is a discriminated union on `action`: `mode` is only + * accepted alongside `action: 'pause'`. Passing `mode` with `action: 'kill'` + * is a compile-time type error. */ export type SandboxOnTimeout = | 'pause' @@ -441,22 +483,27 @@ export type SandboxOnTimeout = action: 'pause' /** - * Whether the timeout auto-pause keeps a full memory snapshot. + * What the timeout auto-pause persists. * - * When `false`, the auto-pause drops the in-memory state and persists only - * the filesystem (a filesystem-only snapshot); resuming such a sandbox - * cold-boots (reboots) it from disk, losing running processes and open - * connections. + * With `'filesystem'`, the auto-pause drops the in-memory state and + * persists only the filesystem; resuming such a sandbox cold-boots + * (reboots) it from disk, losing running processes and open connections. * - * Cannot be combined with `autoResume`: auto-resume wakes a paused sandbox - * on inbound traffic by restoring its memory snapshot in place, so the - * request that woke it hits an already-running process. A filesystem-only - * snapshot has no memory to restore — resuming cold-boots it — so it can't - * be woken transparently by traffic and must be resumed explicitly via - * `connect()`. + * `'filesystem'` cannot be combined with `autoResume`: auto-resume wakes a + * paused sandbox on inbound traffic by restoring its memory snapshot in + * place, so the request that woke it hits an already-running process. A + * filesystem-only snapshot has no memory to restore — resuming cold-boots + * it — so it can't be woken transparently by traffic and must be resumed + * explicitly via `connect()`. * - * Left unset, the flag is omitted from the create request and the API's own - * default (currently enabled) applies. + * Left unset, the mode is omitted from the create request and the API's + * own default (currently `'full'`) applies. + */ + mode?: SnapshotMode + + /** + * @deprecated Use `mode` instead: `keepMemory: false` is + * `mode: 'filesystem'`, `keepMemory: true` is `mode: 'full'`. */ keepMemory?: boolean } @@ -468,7 +515,7 @@ export type SandboxOnTimeout = export type SandboxLifecycle = { /** * Action to take when sandbox timeout is reached. Accepts either `'pause'` / - * `'kill'`, or `{ action, keepMemory }` to also control the pause snapshot kind. + * `'kill'`, or `{ action, mode }` to also control the pause snapshot kind. * Omitted from the create request when unset, leaving the API's default * (currently `kill`) in effect. * @@ -481,8 +528,8 @@ export type SandboxLifecycle = { * * Leave unset to let the API pick the behavior. Set `false` to opt out * explicitly and keep auto-resume off even if the API's default changes. - * Can be `true` only when `onTimeout` is `pause`. Not supported when - * `keepMemory` is `false` (a filesystem-only snapshot must be resumed + * Can be `true` only when `onTimeout` is `pause`. Not supported with + * `mode: 'filesystem'` (a filesystem-only snapshot must be resumed * explicitly via `connect()`). */ autoResume?: boolean @@ -523,11 +570,19 @@ export interface SandboxApiOpts extends Partial< */ export interface SandboxPauseOpts extends SandboxApiOpts { /** - * Whether to keep a full memory snapshot. + * What the pause persists. * - * When `false`, the in-memory state is dropped and only the filesystem is - * persisted (a filesystem-only snapshot); resuming such a sandbox cold-boots - * (reboots) it from disk, losing running processes and open connections. + * With `'filesystem'`, the in-memory state is dropped and only the + * filesystem is persisted; resuming such a sandbox cold-boots (reboots) it + * from disk, losing running processes and open connections. + * + * @default 'full' + */ + mode?: SnapshotMode + + /** + * @deprecated Use `mode` instead: `keepMemory: false` is + * `mode: 'filesystem'`, `keepMemory: true` is `mode: 'full'`. */ keepMemory?: boolean } @@ -850,6 +905,18 @@ export interface CreateSnapshotOpts extends SandboxApiOpts { * to the existing template instead of creating a new one. */ name?: string + /** + * What the snapshot persists. + * + * With `'filesystem'`, only the filesystem is persisted: the snapshot is + * smaller and faster to take, and sandboxes created from it cold-boot (start + * fresh from disk) instead of restoring memory, so they begin without the + * running processes, in-memory state and open connections of the source + * sandbox. The source sandbox keeps running either way. + * + * @default 'full' + */ + mode?: SnapshotMode } /** @@ -1522,7 +1589,7 @@ export class SandboxApi extends ClientFactory { * Pause the sandbox specified by sandbox ID. * * @param sandboxId sandbox ID. - * @param opts pause options, including `keepMemory` and connection options. + * @param opts pause options, including `mode` and connection options. * * @returns `true` if the sandbox got paused, `false` if the sandbox was already paused. */ @@ -1541,7 +1608,7 @@ export class SandboxApi extends ClientFactory { }, }, body: { - memory: apiOpts?.keepMemory, + memory: snapshotMemory('mode', apiOpts?.mode, apiOpts?.keepMemory), }, signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), }) @@ -1581,7 +1648,15 @@ export class SandboxApi extends ClientFactory { * The snapshot is a persistent image that survives sandbox deletion. * * @param sandboxId sandbox ID to create snapshot from. - * @param opts snapshot creation options including optional name and connection options. + * @param opts snapshot creation options including optional name, `mode` + * (`'filesystem'` takes a filesystem-only snapshot), and connection options. + * + * @throws {@link SandboxError} with `statusCode` 400 when `mode: 'filesystem'` + * is requested but the feature is not enabled for the team + * (`snapshot_filesystem_only_disabled`), and with `statusCode` 409 when the + * sandbox's node runs an orchestrator that predates the option + * (`snapshot_filesystem_only_unsupported_node`). A full memory snapshot + * still works in both cases; for 409, pause and resume the sandbox and retry. * * @returns snapshot information including the snapshot name that can be used with Sandbox.create(). */ @@ -1599,7 +1674,10 @@ export class SandboxApi extends ClientFactory { sandboxID: sandboxId, }, }, - body: apiOpts?.name ? { name: apiOpts.name } : {}, + body: { + ...(apiOpts?.name ? { name: apiOpts.name } : {}), + memory: snapshotMemory('mode', apiOpts?.mode), + }, signal: config.getSignal(apiOpts?.requestTimeoutMs, apiOpts?.signal), }) @@ -1672,8 +1750,9 @@ export class SandboxApi extends ClientFactory { opts?: SandboxOpts ) { // onTimeout accepts a bare action (`'pause'` / `'kill'`) or the object form - // `{ action, keepMemory }`. The discriminated union type forbids `keepMemory` - // on `action: 'kill'`; re-check at runtime for untyped callers. + // `{ action, mode }`. The discriminated union type forbids `mode` (and the + // deprecated `keepMemory`) on `action: 'kill'`; re-check at runtime for + // untyped callers. const requestedOnTimeout = opts?.lifecycle?.onTimeout // A missing (or explicitly nullish, for untyped callers) onTimeout is not a // choice of `kill` — it leaves the timeout action to the API. Locally it @@ -1694,22 +1773,26 @@ export class SandboxApi extends ClientFactory { // The action never reaches the API — it is resolved here into the boolean // autoPause — so an unrecognized value cannot be rejected server-side, and // resolving it to kill would delete the sandbox a caller asked to preserve. - const hasKeepMemory = - typeof onTimeout !== 'string' && 'keepMemory' in onTimeout - const keepMemory = - typeof onTimeout !== 'string' && 'keepMemory' in onTimeout - ? (onTimeout.keepMemory ?? true) - : true + const pauseOpts = + typeof onTimeout === 'string' + ? {} + : (onTimeout as { mode?: SnapshotMode; keepMemory?: boolean }) + const hasMode = 'mode' in pauseOpts || 'keepMemory' in pauseOpts // A missing autoResume (or an explicit null from an untyped caller) is left // out of the request entirely, so the API keeps ownership of the default // instead of receiving the SDK's local one as an explicit opt-out. const autoResume = opts?.lifecycle?.autoResume ?? undefined - if (hasKeepMemory && action !== 'pause') { + if (hasMode && action !== 'pause') { throw new InvalidArgumentError( - "onTimeout.keepMemory is only allowed when action is 'pause'." + "onTimeout.mode is only allowed when action is 'pause'." ) } + const memory = snapshotMemory( + 'onTimeout.mode', + pauseOpts.mode, + pauseOpts.keepMemory + ) if (autoResume && action !== 'pause') { throw new InvalidArgumentError( @@ -1717,9 +1800,9 @@ export class SandboxApi extends ClientFactory { ) } - if (!keepMemory && autoResume) { + if (memory === false && autoResume) { throw new InvalidArgumentError( - 'autoResume: true is not a valid value when keepMemory: false - a filesystem-only snapshot cannot be auto-resumed by traffic and must be resumed explicitly using Sandbox.connect().' + "autoResume: true is not a valid value with mode: 'filesystem' - a filesystem-only snapshot cannot be auto-resumed by traffic and must be resumed explicitly using Sandbox.connect()." ) } @@ -1738,8 +1821,7 @@ export class SandboxApi extends ClientFactory { network: buildNetworkBody(opts?.network, iam), iam, autoPause: onTimeoutConfigured ? action === 'pause' : undefined, - autoPauseMemory: - action === 'pause' && hasKeepMemory ? keepMemory : undefined, + autoPauseMemory: action === 'pause' ? memory : undefined, autoResume: autoResume === undefined ? undefined : { enabled: autoResume }, } diff --git a/packages/js-sdk/tests/sandbox/apiDefaults.test.ts b/packages/js-sdk/tests/sandbox/apiDefaults.test.ts index 750f4f948c..3f98484460 100644 --- a/packages/js-sdk/tests/sandbox/apiDefaults.test.ts +++ b/packages/js-sdk/tests/sandbox/apiDefaults.test.ts @@ -8,6 +8,7 @@ import { setupMockApi } from '../mockApi' let lastCreateBody: Record | undefined let lastForkBody: Record | undefined let lastPauseBody: Record | undefined +let lastSnapshotBody: Record | undefined let lastConnectBody: Record | undefined const server = setupMockApi( @@ -42,6 +43,13 @@ const server = setupMockApi( http.post(apiUrl('/sandboxes/:sandboxID/pause'), async ({ request }) => { lastPauseBody = (await request.json()) as Record return new HttpResponse(null, { status: 204 }) + }), + http.post(apiUrl('/sandboxes/:sandboxID/snapshots'), async ({ request }) => { + lastSnapshotBody = (await request.json()) as Record + return HttpResponse.json( + { snapshotID: 'test-snapshot-id', names: [] }, + { status: 201 } + ) }) ) @@ -53,6 +61,7 @@ afterEach(() => { lastCreateBody = undefined lastForkBody = undefined lastPauseBody = undefined + lastSnapshotBody = undefined lastConnectBody = undefined server.resetHandlers() }) @@ -124,14 +133,23 @@ test.each([0, -1, 21, 101, 1.5])( } ) -test('Sandbox.pause omits memory when keepMemory is unset', async () => { +test('Sandbox.pause omits memory when mode is unset', async () => { await Sandbox.pause('test-sandbox-id', { apiKey: TEST_API_KEY }) expect(lastPauseBody).toBeDefined() expect(lastPauseBody).not.toHaveProperty('memory') }) -test('Sandbox.pause sends an explicit keepMemory', async () => { +test.each([ + ['filesystem', false], + ['full', true], +] as const)('Sandbox.pause maps mode %s to memory %s', async (mode, memory) => { + await Sandbox.pause('test-sandbox-id', { apiKey: TEST_API_KEY, mode }) + + expect(lastPauseBody?.memory).toBe(memory) +}) + +test('Sandbox.pause still sends the deprecated keepMemory', async () => { await Sandbox.pause('test-sandbox-id', { apiKey: TEST_API_KEY, keepMemory: false, @@ -140,6 +158,53 @@ test('Sandbox.pause sends an explicit keepMemory', async () => { expect(lastPauseBody?.memory).toBe(false) }) +test('Sandbox.pause rejects mode together with keepMemory', async () => { + await expect( + Sandbox.pause('test-sandbox-id', { + apiKey: TEST_API_KEY, + mode: 'full', + keepMemory: true, + }) + ).rejects.toThrowError(InvalidArgumentError) + expect(lastPauseBody).toBeUndefined() +}) + +test('Sandbox.pause rejects an unknown mode', async () => { + await expect( + Sandbox.pause('test-sandbox-id', { + apiKey: TEST_API_KEY, + mode: 'memory' as never, + }) + ).rejects.toThrow( + new InvalidArgumentError( + 'mode must be one of: full, filesystem (got "memory").' + ) + ) + expect(lastPauseBody).toBeUndefined() +}) + +test('Sandbox.createSnapshot omits memory when mode is unset', async () => { + await Sandbox.createSnapshot('test-sandbox-id', { apiKey: TEST_API_KEY }) + + expect(lastSnapshotBody).toBeDefined() + expect(lastSnapshotBody).not.toHaveProperty('memory') +}) + +test.each([ + ['filesystem', false], + ['full', true], +] as const)( + 'Sandbox.createSnapshot maps mode %s to memory %s', + async (mode, memory) => { + await Sandbox.createSnapshot('test-sandbox-id', { + apiKey: TEST_API_KEY, + mode, + }) + + expect(lastSnapshotBody?.memory).toBe(memory) + } +) + test('Sandbox.connect omits timeout when unset', async () => { await Sandbox.connect('test-sandbox-id', { apiKey: TEST_API_KEY }) diff --git a/packages/js-sdk/tests/sandbox/files/entryInfo.test.ts b/packages/js-sdk/tests/sandbox/files/entryInfo.test.ts index 9d8371051a..a5d84da9f3 100644 --- a/packages/js-sdk/tests/sandbox/files/entryInfo.test.ts +++ b/packages/js-sdk/tests/sandbox/files/entryInfo.test.ts @@ -7,7 +7,7 @@ import { } from '../../../src/envd/filesystem/filesystem_pb' import { FileType, mapEntryInfo } from '../../../src/sandbox/filesystem' -function entry(type: FsFileType, symlinkTarget?: string) { +function entry(type: FsFileType, symlinkTarget?: string, isSymlink = false) { return create(EntryInfoSchema, { name: 'entry', type, @@ -18,6 +18,7 @@ function entry(type: FsFileType, symlinkTarget?: string) { owner: 'user', group: 'user', symlinkTarget, + isSymlink, }) } @@ -32,3 +33,16 @@ test('mapEntryInfo keeps the symlink target on symlink entries', () => { expect(info.type).toBe(FileType.SYMLINK) expect(info.symlinkTarget).toBe('/home/user/a.txt') }) + +test('mapEntryInfo reports a symlink when envd marks the entry itself as one', () => { + // Newer envd puts the target's type in `type` and sets `isSymlink` on the link. + const toDir = mapEntryInfo( + entry(FsFileType.DIRECTORY, '/home/user/releases/v1', true) + ) + expect(toDir.type).toBe(FileType.SYMLINK) + expect(toDir.symlinkTarget).toBe('/home/user/releases/v1') + const toFile = mapEntryInfo(entry(FsFileType.FILE, '/home/user/a.txt', true)) + expect(toFile.type).toBe(FileType.SYMLINK) + // Without the flag the target type stands, as for a plain file or directory. + expect(mapEntryInfo(entry(FsFileType.DIRECTORY)).type).toBe(FileType.DIR) +}) diff --git a/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts b/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts index e4aeb905f6..dc887821bd 100644 --- a/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts +++ b/packages/js-sdk/tests/sandbox/lifecyclePayload.test.ts @@ -53,11 +53,11 @@ test.skipIf(isDebug)( test.skipIf(isDebug)( 'filesystem-only auto-pause reboots on connect', async () => { - // keepMemory:false makes the timeout auto-pause filesystem-only, so resuming + // mode: 'filesystem' makes the timeout auto-pause filesystem-only, so resuming // cold-boots the sandbox from disk. const sandbox = await Sandbox.create(template, { timeoutMs: 3_000, - lifecycle: { onTimeout: { action: 'pause', keepMemory: false } }, + lifecycle: { onTimeout: { action: 'pause', mode: 'filesystem' } }, }) try { diff --git a/packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts b/packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts index 2368be959a..a3e2b53a29 100644 --- a/packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts +++ b/packages/js-sdk/tests/sandbox/lifecycleRequest.test.ts @@ -61,7 +61,7 @@ test('Sandbox.create sends autoPause: true for an explicit pause', async () => { expect(lastCreateBody).not.toHaveProperty('autoResume') }) -test('Sandbox.create omits autoPauseMemory when pause omits keepMemory', async () => { +test('Sandbox.create omits autoPauseMemory when pause omits mode', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY, lifecycle: { onTimeout: { action: 'pause' } }, @@ -75,7 +75,7 @@ test('Sandbox.create omits autoPauseMemory when pause omits keepMemory', async ( test('Sandbox.create sends the pause snapshot kind alongside autoPause', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY, - lifecycle: { onTimeout: { action: 'pause', keepMemory: false } }, + lifecycle: { onTimeout: { action: 'pause', mode: 'filesystem' } }, }) expect(lastCreateBody?.autoPause).toBe(true) @@ -84,7 +84,7 @@ test('Sandbox.create sends the pause snapshot kind alongside autoPause', async ( await Sandbox.create('base', { apiKey: TEST_API_KEY, - lifecycle: { onTimeout: { action: 'pause', keepMemory: true } }, + lifecycle: { onTimeout: { action: 'pause', mode: 'full' } }, }) expect(lastCreateBody?.autoPause).toBe(true) @@ -92,6 +92,16 @@ test('Sandbox.create sends the pause snapshot kind alongside autoPause', async ( expect(lastCreateBody).not.toHaveProperty('autoResume') }) +test('Sandbox.create still sends the deprecated keepMemory', async () => { + await Sandbox.create('base', { + apiKey: TEST_API_KEY, + lifecycle: { onTimeout: { action: 'pause', keepMemory: false } }, + }) + + expect(lastCreateBody?.autoPause).toBe(true) + expect(lastCreateBody?.autoPauseMemory).toBe(false) +}) + test('Sandbox.create omits autoPause for a lifecycle without onTimeout', async () => { // Untyped callers can build the lifecycle conditionally and leave // onTimeout out, or pass it as null; neither selects an action. @@ -147,19 +157,36 @@ async function expectInvalidLifecycleWithoutApiKey( } test('filesystem-only auto-pause with auto-resume is InvalidArgumentError without an API key', async () => { + await expectInvalidLifecycleWithoutApiKey({ + onTimeout: { action: 'pause', mode: 'filesystem' }, + autoResume: true, + }) await expectInvalidLifecycleWithoutApiKey({ onTimeout: { action: 'pause', keepMemory: false }, autoResume: true, }) }) -test('keepMemory on kill is InvalidArgumentError without an API key', async () => { +test('mode on kill is InvalidArgumentError without an API key', async () => { + await expectInvalidLifecycleWithoutApiKey({ + // @ts-expect-error mode is not allowed with action: 'kill' + onTimeout: { action: 'kill', mode: 'filesystem' }, + }) await expectInvalidLifecycleWithoutApiKey({ // @ts-expect-error keepMemory is not allowed with action: 'kill' onTimeout: { action: 'kill', keepMemory: false }, }) }) +test('an unknown or doubled pause mode is InvalidArgumentError without an API key', async () => { + await expectInvalidLifecycleWithoutApiKey({ + onTimeout: { action: 'pause', mode: 'memory' as never }, + }) + await expectInvalidLifecycleWithoutApiKey({ + onTimeout: { action: 'pause', mode: 'full', keepMemory: true }, + }) +}) + test('an explicit autoResume: false is sent', async () => { await Sandbox.create('base', { apiKey: TEST_API_KEY, diff --git a/packages/js-sdk/tests/sandbox/snapshot-api.test.ts b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts index 1fb573e8ac..3c69a3e5be 100644 --- a/packages/js-sdk/tests/sandbox/snapshot-api.test.ts +++ b/packages/js-sdk/tests/sandbox/snapshot-api.test.ts @@ -1,7 +1,7 @@ import { assert } from 'vitest' import { sandboxTest, isDebug } from '../setup.js' -import { Sandbox } from '../../src' +import { Sandbox, SandboxError } from '../../src' const SNAPSHOT_CREATE_REQUEST_TIMEOUT_MS = 120_000 @@ -246,3 +246,70 @@ sandboxTest.skipIf(isDebug)( }, 180_000 ) + +sandboxTest.skipIf(isDebug)( + 'create a filesystem-only snapshot', + async ({ sandbox, sandboxTestId, skip }) => { + const testContent = 'filesystem-only snapshot content' + await sandbox.files.write('/home/user/fs-only.txt', testContent) + + // Kernel boot id: it changes only across a real (cold) boot. Read via a + // command, not files.read: envd serves procfs files as an empty 200 + // because it sizes them by stat. + const bootId = async (sbx: Sandbox) => + ( + await sbx.commands.run('cat /proc/sys/kernel/random/boot_id') + ).stdout.trim() + const sourceBoot = await bootId(sandbox) + assert.isTrue(sourceBoot.length > 0) + + let snapshot + try { + snapshot = await sandbox.createSnapshot({ mode: 'filesystem' }) + } catch (error) { + // The API answers 400 while filesystem-only snapshots are not enabled + // for the team; there is nothing to prove in that environment. + if ( + error instanceof SandboxError && + error.statusCode === 400 && + error.message.includes('not enabled for this team') + ) { + skip('filesystem-only snapshots are not enabled for this team') + } + throw error + } + assert.isString(snapshot.snapshotId) + assert.isTrue(snapshot.snapshotId.length > 0) + + try { + // The source sandbox keeps running and was not rebooted. + assert.isTrue(await sandbox.isRunning()) + assert.equal( + await sandbox.files.read('/home/user/fs-only.txt'), + testContent + ) + assert.equal(await bootId(sandbox), sourceBoot) + + // A sandbox created from it has the files and a fresh boot id: it + // cold-booted instead of restoring memory. A memory snapshot would + // carry the source's boot id over, so this is what tells them apart. + const newSandbox = await Sandbox.create(snapshot.snapshotId, { + metadata: { sandboxTestId: `${sandboxTestId}-fs-only` }, + requestTimeoutMs: SNAPSHOT_CREATE_REQUEST_TIMEOUT_MS, + }) + + try { + const content = await newSandbox.files.read('/home/user/fs-only.txt') + assert.equal(content, testContent) + const newBoot = await bootId(newSandbox) + assert.isTrue(newBoot.length > 0) + assert.notEqual(newBoot, sourceBoot) + } finally { + await newSandbox.kill() + } + } finally { + await Sandbox.deleteSnapshot(snapshot.snapshotId) + } + }, + 180_000 +) diff --git a/packages/js-sdk/tests/sandbox/snapshot.test.ts b/packages/js-sdk/tests/sandbox/snapshot.test.ts index 59208a27e0..792d9f585c 100644 --- a/packages/js-sdk/tests/sandbox/snapshot.test.ts +++ b/packages/js-sdk/tests/sandbox/snapshot.test.ts @@ -186,7 +186,7 @@ sandboxTest.skipIf(isDebug)( // Filesystem-only snapshot: no memory is captured, so resuming cold-boots. await sandbox.pause({ - keepMemory: false, + mode: 'filesystem', requestTimeoutMs: 120_000, }) assert.isFalse(await sandbox.isRunning()) diff --git a/packages/python-sdk/e2b/__init__.py b/packages/python-sdk/e2b/__init__.py index 14d05ac66c..b4f1d0984a 100644 --- a/packages/python-sdk/e2b/__init__.py +++ b/packages/python-sdk/e2b/__init__.py @@ -109,6 +109,7 @@ SandboxQuery, SandboxState, SnapshotInfo, + SnapshotMode, ) from .sandbox_async.commands.command_handle import AsyncCommandHandle from .sandbox_async.filesystem.watch_handle import AsyncWatchHandle @@ -259,6 +260,7 @@ "SecretNotFoundException", # Snapshot "SnapshotInfo", + "SnapshotMode", "SnapshotPaginator", "AsyncSnapshotPaginator", # Signature diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_templates.py b/packages/python-sdk/e2b/api/client/api/templates/get_templates.py index de3c19a9a4..efcb57d156 100644 --- a/packages/python-sdk/e2b/api/client/api/templates/get_templates.py +++ b/packages/python-sdk/e2b/api/client/api/templates/get_templates.py @@ -80,7 +80,8 @@ def sync_detailed( List all templates Args: - team_id (Union[Unset, str]): Identifier of the team + team_id (Union[Unset, str]): Identifier of the team, as its UUID or its public project ID + (prj_) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -111,7 +112,8 @@ def sync( List all templates Args: - team_id (Union[Unset, str]): Identifier of the team + team_id (Union[Unset, str]): Identifier of the team, as its UUID or its public project ID + (prj_) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -137,7 +139,8 @@ async def asyncio_detailed( List all templates Args: - team_id (Union[Unset, str]): Identifier of the team + team_id (Union[Unset, str]): Identifier of the team, as its UUID or its public project ID + (prj_) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. @@ -166,7 +169,8 @@ async def asyncio( List all templates Args: - team_id (Union[Unset, str]): Identifier of the team + team_id (Union[Unset, str]): Identifier of the team, as its UUID or its public project ID + (prj_) Raises: errors.UnexpectedStatus: If the server returns an undocumented status code and Client.raise_on_unexpected_status is True. diff --git a/packages/python-sdk/e2b/api/client/api/templates/get_v2_templates.py b/packages/python-sdk/e2b/api/client/api/templates/get_v2_templates.py index d8a35069bc..9ca59c0fbf 100644 --- a/packages/python-sdk/e2b/api/client/api/templates/get_v2_templates.py +++ b/packages/python-sdk/e2b/api/client/api/templates/get_v2_templates.py @@ -96,7 +96,8 @@ def sync_detailed( List all templates Args: - team_id (Union[Unset, str]): Identifier of the team + team_id (Union[Unset, str]): Identifier of the team, as its UUID or its public project ID + (prj_) next_token (Union[Unset, str]): limit (Union[Unset, int]): Default: 100. @@ -133,7 +134,8 @@ def sync( List all templates Args: - team_id (Union[Unset, str]): Identifier of the team + team_id (Union[Unset, str]): Identifier of the team, as its UUID or its public project ID + (prj_) next_token (Union[Unset, str]): limit (Union[Unset, int]): Default: 100. @@ -165,7 +167,8 @@ async def asyncio_detailed( List all templates Args: - team_id (Union[Unset, str]): Identifier of the team + team_id (Union[Unset, str]): Identifier of the team, as its UUID or its public project ID + (prj_) next_token (Union[Unset, str]): limit (Union[Unset, int]): Default: 100. @@ -200,7 +203,8 @@ async def asyncio( List all templates Args: - team_id (Union[Unset, str]): Identifier of the team + team_id (Union[Unset, str]): Identifier of the team, as its UUID or its public project ID + (prj_) next_token (Union[Unset, str]): limit (Union[Unset, int]): Default: 100. diff --git a/packages/python-sdk/e2b/api/client/models/error.py b/packages/python-sdk/e2b/api/client/models/error.py index fec3fa7361..a0a99778ac 100644 --- a/packages/python-sdk/e2b/api/client/models/error.py +++ b/packages/python-sdk/e2b/api/client/models/error.py @@ -17,7 +17,7 @@ class Error: message (str): Error error_code (Union[Unset, str]): Machine-readable semantic error code. Not a closed set; initial values: sandbox_capacity_unavailable, sandbox_placement_timeout, sandbox_no_compatible_node, sandbox_create_failed, - internal_server_error. + internal_server_error, secret_limit_reached. """ code: int diff --git a/packages/python-sdk/e2b/api/client/models/sandbox_snapshot_request.py b/packages/python-sdk/e2b/api/client/models/sandbox_snapshot_request.py index 7088c0a81b..b339334b42 100644 --- a/packages/python-sdk/e2b/api/client/models/sandbox_snapshot_request.py +++ b/packages/python-sdk/e2b/api/client/models/sandbox_snapshot_request.py @@ -15,19 +15,28 @@ class SandboxSnapshotRequest: Attributes: name (Union[Unset, str]): Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + memory (Union[Unset, bool]): Whether to capture a full memory snapshot. When false, only the filesystem is + persisted: the snapshot is smaller and faster to take, and sandboxes created from it cold-boot (start fresh from + disk) instead of restoring memory, so they begin without the source sandbox's running processes, in-memory + state, and open connections. The source sandbox keeps running in both cases. Defaults to true. """ name: Union[Unset, str] = UNSET + memory: Union[Unset, bool] = UNSET additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) def to_dict(self) -> dict[str, Any]: name = self.name + memory = self.memory + field_dict: dict[str, Any] = {} field_dict.update(self.additional_properties) field_dict.update({}) if name is not UNSET: field_dict["name"] = name + if memory is not UNSET: + field_dict["memory"] = memory return field_dict @@ -36,8 +45,11 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: d = dict(src_dict) name = d.pop("name", UNSET) + memory = d.pop("memory", UNSET) + sandbox_snapshot_request = cls( name=name, + memory=memory, ) sandbox_snapshot_request.additional_properties = d diff --git a/packages/python-sdk/e2b/api/client/models/template_build_request_v3.py b/packages/python-sdk/e2b/api/client/models/template_build_request_v3.py index b3e9cd7ff5..09e939f6d1 100644 --- a/packages/python-sdk/e2b/api/client/models/template_build_request_v3.py +++ b/packages/python-sdk/e2b/api/client/models/template_build_request_v3.py @@ -17,7 +17,7 @@ class TemplateBuildRequestV3: "my-template:v1"). If tag is included, it will be treated as if the tag was provided in the tags array. tags (Union[Unset, list[str]]): Tags to assign to the template build alias (Union[Unset, str]): Alias of the template. Deprecated, use name instead. - team_id (Union[Unset, str]): Identifier of the team + team_id (Union[Unset, str]): Identifier of the team, as its UUID or its public project ID (prj_) cpu_count (Union[Unset, int]): CPU cores for the sandbox memory_mb (Union[Unset, int]): Memory for the sandbox in MiB min_free_disk_mb (Union[Unset, int]): Requested minimum free space after the template's build steps, in MiB. diff --git a/packages/python-sdk/e2b/envd/filesystem/filesystem_pb.py b/packages/python-sdk/e2b/envd/filesystem/filesystem_pb.py index fcf8688ace..bd39842c68 100644 --- a/packages/python-sdk/e2b/envd/filesystem/filesystem_pb.py +++ b/packages/python-sdk/e2b/envd/filesystem/filesystem_pb.py @@ -237,7 +237,7 @@ def __init__( entry: EntryInfo | None -_EntryInfoFields: TypeAlias = Literal["name", "type", "path", "size", "mode", "permissions", "owner", "group", "modified_time", "symlink_target", "metadata"] +_EntryInfoFields: TypeAlias = Literal["name", "type", "path", "size", "mode", "permissions", "owner", "group", "modified_time", "symlink_target", "metadata", "is_symlink"] class EntryInfo(Message[_EntryInfoFields]): """ @@ -296,9 +296,15 @@ class EntryInfo(Message[_EntryInfoFields]): ```proto map metadata = 11; ``` + is_symlink: + True when the entry itself is a symlink; type then describes the symlink's target. + + ```proto + bool is_symlink = 12; + ``` """ - __slots__ = ("name", "type", "path", "size", "mode", "permissions", "owner", "group", "modified_time", "symlink_target", "metadata") + __slots__ = ("name", "type", "path", "size", "mode", "permissions", "owner", "group", "modified_time", "symlink_target", "metadata", "is_symlink") if TYPE_CHECKING: @@ -316,6 +322,7 @@ def __init__( modified_time: Timestamp | None = None, symlink_target: str | None = None, metadata: dict[str, str] | None = None, + is_symlink: bool = False, ) -> None: pass @@ -330,6 +337,7 @@ def __init__( modified_time: Timestamp | None symlink_target: str metadata: dict[str, str] + is_symlink: bool _ListDirRequestFields: TypeAlias = Literal["path", "depth"] @@ -805,7 +813,7 @@ class EventType(Enum): _DESC = file_desc( - b'\n\x1bfilesystem/filesystem.proto\x12\nfilesystem\x1a\x1fgoogle/protobuf/timestamp.proto"G\n\x0bMoveRequest\x12\x16\n\x06source\x18\x01 \x01(\tR\x06source\x12 \n\x0bdestination\x18\x02 \x01(\tR\x0bdestination";\n\x0cMoveResponse\x12+\n\x05entry\x18\x01 \x01(\x0b2\x15.filesystem.EntryInfoR\x05entry"$\n\x0eMakeDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path">\n\x0fMakeDirResponse\x12+\n\x05entry\x18\x01 \x01(\x0b2\x15.filesystem.EntryInfoR\x05entry"#\n\rRemoveRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path"\x10\n\x0eRemoveResponse"!\n\x0bStatRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path";\n\x0cStatResponse\x12+\n\x05entry\x18\x01 \x01(\x0b2\x15.filesystem.EntryInfoR\x05entry"\xd1\x03\n\tEntryInfo\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12(\n\x04type\x18\x02 \x01(\x0e2\x14.filesystem.FileTypeR\x04type\x12\x12\n\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n\x04size\x18\x04 \x01(\x03R\x04size\x12\x12\n\x04mode\x18\x05 \x01(\rR\x04mode\x12 \n\x0bpermissions\x18\x06 \x01(\tR\x0bpermissions\x12\x14\n\x05owner\x18\x07 \x01(\tR\x05owner\x12\x14\n\x05group\x18\x08 \x01(\tR\x05group\x12?\n\rmodified_time\x18\t \x01(\x0b2\x1a.google.protobuf.TimestampR\x0cmodifiedTime\x12*\n\x0esymlink_target\x18\n \x01(\tH\x00R\rsymlinkTarget\x88\x01\x01\x12?\n\x08metadata\x18\x0b \x03(\x0b2#.filesystem.EntryInfo.MetadataEntryR\x08metadata\x1a;\n\rMetadataEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x11\n\x0f_symlink_target":\n\x0eListDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n\x05depth\x18\x02 \x01(\rR\x05depth"B\n\x0fListDirResponse\x12/\n\x07entries\x18\x01 \x03(\x0b2\x15.filesystem.EntryInfoR\x07entries"\x9a\x01\n\x0fWatchDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\x12#\n\rinclude_entry\x18\x03 \x01(\x08R\x0cincludeEntry\x120\n\x14allow_network_mounts\x18\x04 \x01(\x08R\x12allowNetworkMounts"\x8c\x01\n\x0fFilesystemEvent\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12)\n\x04type\x18\x02 \x01(\x0e2\x15.filesystem.EventTypeR\x04type\x120\n\x05entry\x18\x03 \x01(\x0b2\x15.filesystem.EntryInfoH\x00R\x05entry\x88\x01\x01B\x08\n\x06_entry"\xfe\x01\n\x10WatchDirResponse\x12?\n\x05start\x18\x01 \x01(\x0b2\'.filesystem.WatchDirResponse.StartEventH\x00R\x05start\x12=\n\nfilesystem\x18\x02 \x01(\x0b2\x1b.filesystem.FilesystemEventH\x00R\nfilesystem\x12F\n\tkeepalive\x18\x03 \x01(\x0b2&.filesystem.WatchDirResponse.KeepAliveH\x00R\tkeepalive\x1a\x0c\n\nStartEvent\x1a\x0b\n\tKeepAliveB\x07\n\x05event"\x9f\x01\n\x14CreateWatcherRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\x12#\n\rinclude_entry\x18\x03 \x01(\x08R\x0cincludeEntry\x120\n\x14allow_network_mounts\x18\x04 \x01(\x08R\x12allowNetworkMounts"6\n\x15CreateWatcherResponse\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId"8\n\x17GetWatcherEventsRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId"O\n\x18GetWatcherEventsResponse\x123\n\x06events\x18\x01 \x03(\x0b2\x1b.filesystem.FilesystemEventR\x06events"5\n\x14RemoveWatcherRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId"\x17\n\x15RemoveWatcherResponse*i\n\x08FileType\x12\x19\n\x15FILE_TYPE_UNSPECIFIED\x10\x00\x12\x12\n\x0eFILE_TYPE_FILE\x10\x01\x12\x17\n\x13FILE_TYPE_DIRECTORY\x10\x02\x12\x15\n\x11FILE_TYPE_SYMLINK\x10\x03*\x98\x01\n\tEventType\x12\x1a\n\x16EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11EVENT_TYPE_CREATE\x10\x01\x12\x14\n\x10EVENT_TYPE_WRITE\x10\x02\x12\x15\n\x11EVENT_TYPE_REMOVE\x10\x03\x12\x15\n\x11EVENT_TYPE_RENAME\x10\x04\x12\x14\n\x10EVENT_TYPE_CHMOD\x10\x052\x9f\x05\n\nFilesystem\x129\n\x04Stat\x12\x17.filesystem.StatRequest\x1a\x18.filesystem.StatResponse\x12B\n\x07MakeDir\x12\x1a.filesystem.MakeDirRequest\x1a\x1b.filesystem.MakeDirResponse\x129\n\x04Move\x12\x17.filesystem.MoveRequest\x1a\x18.filesystem.MoveResponse\x12B\n\x07ListDir\x12\x1a.filesystem.ListDirRequest\x1a\x1b.filesystem.ListDirResponse\x12?\n\x06Remove\x12\x19.filesystem.RemoveRequest\x1a\x1a.filesystem.RemoveResponse\x12G\n\x08WatchDir\x12\x1b.filesystem.WatchDirRequest\x1a\x1c.filesystem.WatchDirResponse0\x01\x12T\n\rCreateWatcher\x12 .filesystem.CreateWatcherRequest\x1a!.filesystem.CreateWatcherResponse\x12]\n\x10GetWatcherEvents\x12#.filesystem.GetWatcherEventsRequest\x1a$.filesystem.GetWatcherEventsResponse\x12T\n\rRemoveWatcher\x12 .filesystem.RemoveWatcherRequest\x1a!.filesystem.RemoveWatcherResponseBi\n\x0ecom.filesystemB\x0fFilesystemProtoP\x01\xa2\x02\x03FXX\xaa\x02\nFilesystem\xca\x02\nFilesystem\xe2\x02\x16Filesystem\\GPBMetadata\xea\x02\nFilesystemb\x06proto3', + b'\n\x1bfilesystem/filesystem.proto\x12\nfilesystem\x1a\x1fgoogle/protobuf/timestamp.proto"G\n\x0bMoveRequest\x12\x16\n\x06source\x18\x01 \x01(\tR\x06source\x12 \n\x0bdestination\x18\x02 \x01(\tR\x0bdestination";\n\x0cMoveResponse\x12+\n\x05entry\x18\x01 \x01(\x0b2\x15.filesystem.EntryInfoR\x05entry"$\n\x0eMakeDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path">\n\x0fMakeDirResponse\x12+\n\x05entry\x18\x01 \x01(\x0b2\x15.filesystem.EntryInfoR\x05entry"#\n\rRemoveRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path"\x10\n\x0eRemoveResponse"!\n\x0bStatRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path";\n\x0cStatResponse\x12+\n\x05entry\x18\x01 \x01(\x0b2\x15.filesystem.EntryInfoR\x05entry"\xf0\x03\n\tEntryInfo\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12(\n\x04type\x18\x02 \x01(\x0e2\x14.filesystem.FileTypeR\x04type\x12\x12\n\x04path\x18\x03 \x01(\tR\x04path\x12\x12\n\x04size\x18\x04 \x01(\x03R\x04size\x12\x12\n\x04mode\x18\x05 \x01(\rR\x04mode\x12 \n\x0bpermissions\x18\x06 \x01(\tR\x0bpermissions\x12\x14\n\x05owner\x18\x07 \x01(\tR\x05owner\x12\x14\n\x05group\x18\x08 \x01(\tR\x05group\x12?\n\rmodified_time\x18\t \x01(\x0b2\x1a.google.protobuf.TimestampR\x0cmodifiedTime\x12*\n\x0esymlink_target\x18\n \x01(\tH\x00R\rsymlinkTarget\x88\x01\x01\x12?\n\x08metadata\x18\x0b \x03(\x0b2#.filesystem.EntryInfo.MetadataEntryR\x08metadata\x12\x1d\n\nis_symlink\x18\x0c \x01(\x08R\tisSymlink\x1a;\n\rMetadataEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x11\n\x0f_symlink_target":\n\x0eListDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x14\n\x05depth\x18\x02 \x01(\rR\x05depth"B\n\x0fListDirResponse\x12/\n\x07entries\x18\x01 \x03(\x0b2\x15.filesystem.EntryInfoR\x07entries"\x9a\x01\n\x0fWatchDirRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\x12#\n\rinclude_entry\x18\x03 \x01(\x08R\x0cincludeEntry\x120\n\x14allow_network_mounts\x18\x04 \x01(\x08R\x12allowNetworkMounts"\x8c\x01\n\x0fFilesystemEvent\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12)\n\x04type\x18\x02 \x01(\x0e2\x15.filesystem.EventTypeR\x04type\x120\n\x05entry\x18\x03 \x01(\x0b2\x15.filesystem.EntryInfoH\x00R\x05entry\x88\x01\x01B\x08\n\x06_entry"\xfe\x01\n\x10WatchDirResponse\x12?\n\x05start\x18\x01 \x01(\x0b2\'.filesystem.WatchDirResponse.StartEventH\x00R\x05start\x12=\n\nfilesystem\x18\x02 \x01(\x0b2\x1b.filesystem.FilesystemEventH\x00R\nfilesystem\x12F\n\tkeepalive\x18\x03 \x01(\x0b2&.filesystem.WatchDirResponse.KeepAliveH\x00R\tkeepalive\x1a\x0c\n\nStartEvent\x1a\x0b\n\tKeepAliveB\x07\n\x05event"\x9f\x01\n\x14CreateWatcherRequest\x12\x12\n\x04path\x18\x01 \x01(\tR\x04path\x12\x1c\n\trecursive\x18\x02 \x01(\x08R\trecursive\x12#\n\rinclude_entry\x18\x03 \x01(\x08R\x0cincludeEntry\x120\n\x14allow_network_mounts\x18\x04 \x01(\x08R\x12allowNetworkMounts"6\n\x15CreateWatcherResponse\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId"8\n\x17GetWatcherEventsRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId"O\n\x18GetWatcherEventsResponse\x123\n\x06events\x18\x01 \x03(\x0b2\x1b.filesystem.FilesystemEventR\x06events"5\n\x14RemoveWatcherRequest\x12\x1d\n\nwatcher_id\x18\x01 \x01(\tR\twatcherId"\x17\n\x15RemoveWatcherResponse*i\n\x08FileType\x12\x19\n\x15FILE_TYPE_UNSPECIFIED\x10\x00\x12\x12\n\x0eFILE_TYPE_FILE\x10\x01\x12\x17\n\x13FILE_TYPE_DIRECTORY\x10\x02\x12\x15\n\x11FILE_TYPE_SYMLINK\x10\x03*\x98\x01\n\tEventType\x12\x1a\n\x16EVENT_TYPE_UNSPECIFIED\x10\x00\x12\x15\n\x11EVENT_TYPE_CREATE\x10\x01\x12\x14\n\x10EVENT_TYPE_WRITE\x10\x02\x12\x15\n\x11EVENT_TYPE_REMOVE\x10\x03\x12\x15\n\x11EVENT_TYPE_RENAME\x10\x04\x12\x14\n\x10EVENT_TYPE_CHMOD\x10\x052\x9f\x05\n\nFilesystem\x129\n\x04Stat\x12\x17.filesystem.StatRequest\x1a\x18.filesystem.StatResponse\x12B\n\x07MakeDir\x12\x1a.filesystem.MakeDirRequest\x1a\x1b.filesystem.MakeDirResponse\x129\n\x04Move\x12\x17.filesystem.MoveRequest\x1a\x18.filesystem.MoveResponse\x12B\n\x07ListDir\x12\x1a.filesystem.ListDirRequest\x1a\x1b.filesystem.ListDirResponse\x12?\n\x06Remove\x12\x19.filesystem.RemoveRequest\x1a\x1a.filesystem.RemoveResponse\x12G\n\x08WatchDir\x12\x1b.filesystem.WatchDirRequest\x1a\x1c.filesystem.WatchDirResponse0\x01\x12T\n\rCreateWatcher\x12 .filesystem.CreateWatcherRequest\x1a!.filesystem.CreateWatcherResponse\x12]\n\x10GetWatcherEvents\x12#.filesystem.GetWatcherEventsRequest\x1a$.filesystem.GetWatcherEventsResponse\x12T\n\rRemoveWatcher\x12 .filesystem.RemoveWatcherRequest\x1a!.filesystem.RemoveWatcherResponseBi\n\x0ecom.filesystemB\x0fFilesystemProtoP\x01\xa2\x02\x03FXX\xaa\x02\nFilesystem\xca\x02\nFilesystem\xe2\x02\x16Filesystem\\GPBMetadata\xea\x02\nFilesystemb\x06proto3', [ timestamp_pb.desc(), ], diff --git a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py index 979ea6b924..af54390e46 100644 --- a/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py +++ b/packages/python-sdk/e2b/sandbox/filesystem/filesystem.py @@ -130,7 +130,9 @@ class EntryInfo(WriteInfo): def map_entry_info(entry: filesystem_pb.EntryInfo) -> EntryInfo: return EntryInfo( name=entry.name, - type=map_file_type(entry.type), + # Newer envd reports a symlink's target type in `type` and marks the + # link itself with `is_symlink`; older envd reports SYMLINK in `type`. + type=FileType.SYMLINK if entry.is_symlink else map_file_type(entry.type), path=entry.path, size=entry.size, mode=entry.mode, diff --git a/packages/python-sdk/e2b/sandbox/sandbox_api.py b/packages/python-sdk/e2b/sandbox/sandbox_api.py index 789d3c5b3d..7b75c8d3f3 100644 --- a/packages/python-sdk/e2b/sandbox/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox/sandbox_api.py @@ -535,29 +535,73 @@ class SandboxNetworkInfo(TypedDict, total=False): https_ports: List[int] +SnapshotMode = Literal["full", "filesystem"] +""" +What a pause or snapshot persists. + +``"full"`` persists the filesystem and the memory (running processes, in-memory +state and open connections); resuming restores the sandbox exactly where it left +off. ``"filesystem"`` persists only the filesystem: smaller and faster to take, +and resuming (or creating a sandbox from the snapshot) cold-boots it from disk, +without the running processes, in-memory state and open connections. +""" + + +def resolve_snapshot_memory( + field: str, + mode: Optional[SnapshotMode], + keep_memory: Optional[bool] = None, +) -> Union[Unset, bool]: + """Resolve ``mode`` (or the deprecated ``keep_memory``) into the API's + ``memory`` flag. + + Unset, it stays ``UNSET`` so the API default (currently a full snapshot) + applies. ``field`` names the option in error messages. + """ + if mode is not None and keep_memory is not None: + raise InvalidArgumentException( + f"Pass either {field} or the deprecated keep_memory, not both." + ) + if mode is None: + return keep_memory if keep_memory is not None else UNSET + # Re-check at runtime for callers that bypass the type. + allowed = ("full", "filesystem") + if mode not in allowed: + raise InvalidArgumentException( + f"{field} must be one of: {', '.join(allowed)} (got {mode!r})." + ) + return mode == "full" + + class SandboxOnTimeoutPause(TypedDict): """ Object form of `on_timeout` that auto-pauses the sandbox when the timeout is - reached, optionally controlling the pause snapshot kind via `keep_memory`. + reached, optionally controlling the pause snapshot kind via `mode`. """ action: Literal["pause"] """Auto-pause the sandbox when the timeout is reached.""" - keep_memory: NotRequired[bool] + mode: NotRequired[SnapshotMode] """ - Whether the timeout auto-pause keeps a full memory snapshot. Left unset, it is - omitted from the create request and the API's own default (currently enabled) - applies. When `False`, the auto-pause drops the in-memory state and persists - only the filesystem (a filesystem-only snapshot); resuming such a sandbox - cold-boots (reboots) it from disk, losing running processes and open - connections. + What the timeout auto-pause persists. Left unset, it is omitted from the + create request and the API's own default (currently `"full"`) applies. With + `"filesystem"`, the auto-pause drops the in-memory state and persists only + the filesystem; resuming such a sandbox cold-boots (reboots) it from disk, + losing running processes and open connections. - Cannot be combined with `auto_resume`: auto-resume wakes a paused sandbox on - inbound traffic by restoring its memory snapshot in place, so the request that - woke it hits an already-running process. A filesystem-only snapshot has no - memory to restore — resuming cold-boots it — so it can't be woken transparently - by traffic and must be resumed explicitly via `connect()`. + `"filesystem"` cannot be combined with `auto_resume`: auto-resume wakes a + paused sandbox on inbound traffic by restoring its memory snapshot in place, + so the request that woke it hits an already-running process. A + filesystem-only snapshot has no memory to restore — resuming cold-boots it — + so it can't be woken transparently by traffic and must be resumed explicitly + via `connect()`. + """ + + keep_memory: NotRequired[bool] + """ + :deprecated: Use `mode` instead: `keep_memory: False` is + `mode: "filesystem"`, `keep_memory: True` is `mode: "full"`. """ @@ -576,9 +620,8 @@ class SandboxOnTimeoutKill(TypedDict): """ What should happen to the sandbox when the timeout is reached. Either the bare action (`"pause"` / `"kill"`) or the object form. The object form is a -discriminated union on `action`: `keep_memory` is only accepted alongside -`action: "pause"`. Passing `keep_memory` with `action: "kill"` is a static type -error. +discriminated union on `action`: `mode` is only accepted alongside +`action: "pause"`. Passing `mode` with `action: "kill"` is a static type error. """ @@ -593,7 +636,7 @@ class SandboxLifecycle(TypedDict): """ What should happen to the sandbox when timeout is reached. `"kill"` terminates the sandbox; `"pause"` pauses it for later resume. Accepts either the bare - action or an object `{"action": "pause", "keep_memory": ...}` / + action or an object `{"action": "pause", "mode": ...}` / `{"action": "kill"}` to also control the pause snapshot kind. A value outside the two actions raises `InvalidArgumentException`. Omitted from the create request when unset, leaving the API's default (currently `"kill"`) in @@ -605,8 +648,8 @@ class SandboxLifecycle(TypedDict): Whether activity should cause the sandbox to resume when paused. Leave unset to let the API pick the behavior. Set `False` to opt out explicitly and keep auto-resume off even if the API's default changes. Can be `True` only when - `on_timeout` is `pause`. Not supported when `keep_memory` is `False` - (a filesystem-only snapshot must be resumed explicitly via `connect()`). + `on_timeout` is `pause`. Not supported with `mode: "filesystem"` (a + filesystem-only snapshot must be resumed explicitly via `connect()`). """ @@ -901,25 +944,23 @@ def build_lifecycle_config( ``auto_pause`` is left unset when no ``on_timeout`` was configured: sending ``False`` would be indistinguishable from an explicit ``"kill"`` and would override the default the API owns. ``auto_pause_memory`` is likewise left - unset unless the caller chose ``keep_memory``. + unset unless the caller chose ``mode``. """ - # on_timeout accepts a bare action or {"action", "keep_memory"}; normalize. - # Only the object form carries keep_memory. + # on_timeout accepts a bare action or {"action", "mode"}; normalize. + # Only the object form carries mode (or the deprecated keep_memory). on_timeout_raw = lifecycle.get("on_timeout") if lifecycle else None # A missing on_timeout — or an explicit None from an untyped caller — is not # a choice of kill. It only resolves to kill semantics locally, for the - # validation below and for keep_memory. + # validation below. on_timeout_configured = on_timeout_raw is not None if isinstance(on_timeout_raw, dict): on_timeout = on_timeout_raw.get("action") - keep_memory_provided = "keep_memory" in on_timeout_raw - keep_memory = on_timeout_raw.get("keep_memory") + pause_opts = cast(Dict[str, Any], on_timeout_raw) else: # Only fall back when unconfigured, not on other falsy-but-present # values an untyped caller might pass. on_timeout = on_timeout_raw if on_timeout_configured else "kill" - keep_memory = None - keep_memory_provided = False + pause_opts = {} allowed_actions = ("pause", "kill") if on_timeout_configured and on_timeout not in allowed_actions: @@ -935,19 +976,15 @@ def build_lifecycle_config( # auto_pause — so an unrecognized value cannot be rejected server-side, and # resolving it to kill would delete the sandbox a caller asked to preserve. - # keep_memory only governs a pause action. The discriminated union type - # forbids it on action="kill"; re-check at runtime for callers that - # bypass the type. - if keep_memory_provided and on_timeout != "pause": + # mode only governs a pause action. The discriminated union type forbids it + # on action="kill"; re-check at runtime for callers that bypass the type. + if ("mode" in pause_opts or "keep_memory" in pause_opts) and on_timeout != "pause": raise InvalidArgumentException( - "keep_memory is only allowed when on_timeout action is 'pause'." + "mode is only allowed when on_timeout action is 'pause'." ) - - # A missing or explicit None keep_memory defaults to True (full memory) for - # local validation below. The wire field is omitted unless keep_memory was - # actually provided. - if keep_memory is None: - keep_memory = True + memory = resolve_snapshot_memory( + 'on_timeout["mode"]', pause_opts.get("mode"), pause_opts.get("keep_memory") + ) auto_resume = lifecycle.get("auto_resume") if lifecycle else None if auto_resume and on_timeout != "pause": @@ -955,18 +992,16 @@ def build_lifecycle_config( "auto_resume can only be True when on_timeout action is 'pause'." ) - if not keep_memory and auto_resume: + if memory is False and auto_resume: raise InvalidArgumentException( - "auto_resume: True is not a valid value when keep_memory: False - " + "auto_resume: True is not a valid value with mode: 'filesystem' - " "a filesystem-only snapshot cannot be auto-resumed by traffic and " "must be resumed explicitly using Sandbox.connect()." ) return SandboxLifecycleBody( auto_pause=(on_timeout == "pause") if on_timeout_configured else UNSET, - auto_pause_memory=( - keep_memory if on_timeout == "pause" and keep_memory_provided else UNSET - ), + auto_pause_memory=memory if on_timeout == "pause" else UNSET, auto_resume=( ClientSandboxAutoResumeConfig(enabled=auto_resume) if auto_resume is not None diff --git a/packages/python-sdk/e2b/sandbox_async/main.py b/packages/python-sdk/e2b/sandbox_async/main.py index 32dd2ba2ed..3d0a648f50 100644 --- a/packages/python-sdk/e2b/sandbox_async/main.py +++ b/packages/python-sdk/e2b/sandbox_async/main.py @@ -31,6 +31,7 @@ SandboxNetworkUpdate, SandboxOnResume, SnapshotInfo, + SnapshotMode, ) from e2b.sandbox.utils import class_method_variant from e2b.sandbox_async.commands.command import Commands @@ -199,7 +200,7 @@ async def create( :param mcp: MCP server to enable in the sandbox :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``). :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request - :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` + :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "mode": "full"|"filesystem"}`` where ``mode`` set to ``"filesystem"`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``mode`` leaves the snapshot kind to the API (``keep_memory`` is a deprecated alias); ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "mode": "filesystem"}}`` :param volume_mounts: Dictionary mapping mount paths to AsyncVolume instances or volume names :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. @@ -786,12 +787,15 @@ async def get_metrics( async def pause( self, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. + :param keep_memory: :deprecated: Use `mode` instead: `keep_memory=False` is `mode="filesystem"`, `keep_memory=True` is `mode="full"`. + :param mode: What the pause persists. With `"filesystem"`, the in-memory state is dropped and only the filesystem is persisted; resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `"full"` (memory and filesystem). :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -802,13 +806,16 @@ async def pause( async def pause( sandbox_id: str, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox specified by sandbox ID. :param sandbox_id: Sandbox ID - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. + :param keep_memory: :deprecated: Use `mode` instead: `keep_memory=False` is `mode="filesystem"`, `keep_memory=True` is `mode="full"`. + :param mode: What the pause persists. With `"filesystem"`, the in-memory state is dropped and only the filesystem is persisted; resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `"full"` (memory and filesystem). :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -818,12 +825,15 @@ async def pause( async def pause( self, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. + :param keep_memory: :deprecated: Use `mode` instead: `keep_memory=False` is `mode="filesystem"`, `keep_memory=True` is `mode="full"`. + :param mode: What the pause persists. With `"filesystem"`, the in-memory state is dropped and only the filesystem is persisted; resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `"full"` (memory and filesystem). :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -831,6 +841,7 @@ async def pause( return await SandboxApi._cls_pause( sandbox_id=self.sandbox_id, keep_memory=keep_memory, + mode=mode, **self.connection_config.get_api_params(**opts), ) @@ -838,6 +849,8 @@ async def pause( async def beta_pause( self, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: ... @@ -846,6 +859,8 @@ async def beta_pause( async def beta_pause( sandbox_id: str, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: ... @@ -853,6 +868,8 @@ async def beta_pause( async def beta_pause( self, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: """ @@ -860,12 +877,14 @@ async def beta_pause( :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ - return await self.pause(keep_memory=keep_memory, **opts) + return await self.pause(keep_memory=keep_memory, mode=mode, **opts) @overload async def create_snapshot( self, name: Optional[str] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -878,6 +897,8 @@ async def create_snapshot( Use the returned `snapshot_id` with `AsyncSandbox.create(snapshot_id)` to create a new sandbox from the snapshot. :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + :param mode: What the snapshot persists. With `"filesystem"`, only the filesystem is persisted: the snapshot is smaller and faster to take, and sandboxes created from it cold-boot (start fresh from disk) instead of restoring memory, so they begin without the running processes, in-memory state and open connections of the source sandbox. The source sandbox keeps running either way. Defaults to `"full"` (memory and filesystem). + :raises SandboxException: when the API refuses a filesystem-only snapshot (``mode="filesystem"``). ``status_code`` 400 means the feature is not enabled for the team (``snapshot_filesystem_only_disabled``); 409 means the sandbox's node runs an orchestrator that predates the option (``snapshot_filesystem_only_unsupported_node``). A full memory snapshot still works in both cases; for 409, pause and resume the sandbox and retry. :return: Snapshot information including the snapshot ID and names """ @@ -888,6 +909,8 @@ async def create_snapshot( async def create_snapshot( sandbox_id: str, name: Optional[str] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -897,6 +920,8 @@ async def create_snapshot( :param sandbox_id: Sandbox ID :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + :param mode: What the snapshot persists. With `"filesystem"`, only the filesystem is persisted: the snapshot is smaller and faster to take, and sandboxes created from it cold-boot (start fresh from disk) instead of restoring memory, so they begin without the running processes, in-memory state and open connections of the source sandbox. The source sandbox keeps running either way. Defaults to `"full"` (memory and filesystem). + :raises SandboxException: when the API refuses a filesystem-only snapshot (``mode="filesystem"``). ``status_code`` 400 means the feature is not enabled for the team (``snapshot_filesystem_only_disabled``); 409 means the sandbox's node runs an orchestrator that predates the option (``snapshot_filesystem_only_unsupported_node``). A full memory snapshot still works in both cases; for 409, pause and resume the sandbox and retry. :return: Snapshot information including the snapshot ID and names """ @@ -906,6 +931,8 @@ async def create_snapshot( async def create_snapshot( self, name: Optional[str] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -918,12 +945,15 @@ async def create_snapshot( Use the returned `snapshot_id` with `AsyncSandbox.create(snapshot_id)` to create a new sandbox from the snapshot. :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + :param mode: What the snapshot persists. With `"filesystem"`, only the filesystem is persisted: the snapshot is smaller and faster to take, and sandboxes created from it cold-boot (start fresh from disk) instead of restoring memory, so they begin without the running processes, in-memory state and open connections of the source sandbox. The source sandbox keeps running either way. Defaults to `"full"` (memory and filesystem). + :raises SandboxException: when the API refuses a filesystem-only snapshot (``mode="filesystem"``). ``status_code`` 400 means the feature is not enabled for the team (``snapshot_filesystem_only_disabled``); 409 means the sandbox's node runs an orchestrator that predates the option (``snapshot_filesystem_only_unsupported_node``). A full memory snapshot still works in both cases; for 409, pause and resume the sandbox and retry. :return: Snapshot information including the snapshot ID and names """ return await SandboxApi._cls_create_snapshot( sandbox_id=self.sandbox_id, name=name, + mode=mode, **self.connection_config.get_api_params(**opts), ) diff --git a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py index fb6b29ef4b..e41f8ff397 100644 --- a/packages/python-sdk/e2b/sandbox_async/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_async/sandbox_api.py @@ -56,7 +56,9 @@ SandboxNetworkOpts, SandboxNetworkUpdate, SandboxOnResume, + SnapshotMode, resolve_connect_memory, + resolve_snapshot_memory, SandboxQuery, SnapshotInfo, build_iam_config, @@ -336,6 +338,8 @@ async def _cls_create_snapshot( cls, sandbox_id: str, name: Optional[str] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: config = ConnectionConfig(**cls._resolve_api_params(**opts)) @@ -344,7 +348,10 @@ async def _cls_create_snapshot( res = await post_sandboxes_sandbox_id_snapshots.asyncio_detailed( sandbox_id, client=api_client, - body=SandboxSnapshotRequest(name=name if name else UNSET), + body=SandboxSnapshotRequest( + name=name if name else UNSET, + memory=resolve_snapshot_memory("mode", mode), + ), ) if res.status_code == 404: @@ -391,6 +398,8 @@ async def _cls_pause( cls, sandbox_id: str, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: config = ConnectionConfig(**cls._resolve_api_params(**opts)) @@ -400,7 +409,7 @@ async def _cls_pause( sandbox_id, client=api_client, body=SandboxPauseRequest( - memory=keep_memory if keep_memory is not None else UNSET + memory=resolve_snapshot_memory("mode", mode, keep_memory) ), ) diff --git a/packages/python-sdk/e2b/sandbox_sync/main.py b/packages/python-sdk/e2b/sandbox_sync/main.py index 22d57c5e6b..32c1b671b7 100644 --- a/packages/python-sdk/e2b/sandbox_sync/main.py +++ b/packages/python-sdk/e2b/sandbox_sync/main.py @@ -30,6 +30,7 @@ SandboxNetworkUpdate, SandboxOnResume, SnapshotInfo, + SnapshotMode, ) from e2b.sandbox.utils import class_method_variant from e2b.sandbox_sync.commands.command import Commands @@ -195,7 +196,7 @@ def create( :param mcp: MCP server to enable in the sandbox :param network: Sandbox network configuration. ``allow_out``/``deny_out`` may also be a callable receiving a :class:`SandboxNetworkSelectorContext` (``ctx.all_traffic``, ``ctx.rules``) and returning a list of strings. Per-host transform rules are nested under ``network.rules``; a rule's ``transform`` may be a callable receiving a :class:`SandboxNetworkTransformContext` of placeholder strings (``ctx.iam.tokens[name]``). :param iam: Sandbox workload identity configuration. A non-empty ``tokens`` map enables workload identity for the sandbox; token definitions can be created with :meth:`Secret.iam_token`. Example: ``{"tokens": {"aws": Secret.iam_token(audience="sts.amazonaws.com", token_type="JWT-SVID")}}``. Registered tokens are exposed to ``network.rules`` ``transform`` callables as ``ctx.iam.tokens[name]`` placeholders, which the egress proxy resolves per request - :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "keep_memory": bool}`` where ``keep_memory`` set to ``False`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``keep_memory`` leaves the snapshot kind to the API; ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "keep_memory": False}}`` + :param lifecycle: Sandbox lifecycle configuration — ``on_timeout``: ``"kill"`` or ``"pause"`` (omitted from the request when unset, leaving the API's default, currently ``"kill"``, in effect), or an object ``{"action": "pause"|"kill", "mode": "full"|"filesystem"}`` where ``mode`` set to ``"filesystem"`` makes a timeout auto-pause filesystem-only (cold-boots on resume; cannot be combined with ``auto_resume``); an omitted ``mode`` leaves the snapshot kind to the API (``keep_memory`` is a deprecated alias); ``auto_resume``: leave unset to let the API pick the behavior, set ``False`` to opt out explicitly, or ``True`` (only when ``on_timeout`` action is ``"pause"``). Example: ``{"on_timeout": {"action": "pause", "mode": "filesystem"}}`` :param volume_mounts: Dictionary mapping mount paths to Volume instances or volume names :param logger: Logger used for request and response logging for this sandbox. Accepts any standard library `logging.Logger`. When omitted, no request/response logging is emitted. @@ -784,12 +785,15 @@ def get_metrics( def pause( self, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. + :param keep_memory: :deprecated: Use `mode` instead: `keep_memory=False` is `mode="filesystem"`, `keep_memory=True` is `mode="full"`. + :param mode: What the pause persists. With `"filesystem"`, the in-memory state is dropped and only the filesystem is persisted; resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `"full"` (memory and filesystem). :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -800,13 +804,16 @@ def pause( def pause( sandbox_id: str, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox specified by sandbox ID. :param sandbox_id: Sandbox ID - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk. + :param keep_memory: :deprecated: Use `mode` instead: `keep_memory=False` is `mode="filesystem"`, `keep_memory=True` is `mode="full"`. + :param mode: What the pause persists. With `"filesystem"`, the in-memory state is dropped and only the filesystem is persisted; resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `"full"` (memory and filesystem). :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -816,12 +823,15 @@ def pause( def pause( self, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: """ Pause the sandbox. - :param keep_memory: When `False`, the in-memory state is dropped and only the filesystem is persisted (no memory snapshot); resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. + :param keep_memory: :deprecated: Use `mode` instead: `keep_memory=False` is `mode="filesystem"`, `keep_memory=True` is `mode="full"`. + :param mode: What the pause persists. With `"filesystem"`, the in-memory state is dropped and only the filesystem is persisted; resuming such a sandbox cold-boots (reboots) it from disk, losing running processes and open connections. Defaults to `"full"` (memory and filesystem). :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ @@ -829,6 +839,7 @@ def pause( return SandboxApi._cls_pause( sandbox_id=self.sandbox_id, keep_memory=keep_memory, + mode=mode, **self.connection_config.get_api_params(**opts), ) @@ -836,6 +847,8 @@ def pause( def beta_pause( self, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: ... @@ -844,6 +857,8 @@ def beta_pause( def beta_pause( sandbox_id: str, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: ... @@ -851,6 +866,8 @@ def beta_pause( def beta_pause( self, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: """ @@ -858,12 +875,14 @@ def beta_pause( :return: `True` if the sandbox got paused, `False` if the sandbox was already paused """ - return self.pause(keep_memory=keep_memory, **opts) + return self.pause(keep_memory=keep_memory, mode=mode, **opts) @overload def create_snapshot( self, name: Optional[str] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -876,6 +895,8 @@ def create_snapshot( Use the returned `snapshot_id` with `Sandbox.create(snapshot_id)` to create a new sandbox from the snapshot. :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + :param mode: What the snapshot persists. With `"filesystem"`, only the filesystem is persisted: the snapshot is smaller and faster to take, and sandboxes created from it cold-boot (start fresh from disk) instead of restoring memory, so they begin without the running processes, in-memory state and open connections of the source sandbox. The source sandbox keeps running either way. Defaults to `"full"` (memory and filesystem). + :raises SandboxException: when the API refuses a filesystem-only snapshot (``mode="filesystem"``). ``status_code`` 400 means the feature is not enabled for the team (``snapshot_filesystem_only_disabled``); 409 means the sandbox's node runs an orchestrator that predates the option (``snapshot_filesystem_only_unsupported_node``). A full memory snapshot still works in both cases; for 409, pause and resume the sandbox and retry. :return: Snapshot information including the snapshot ID and names """ @@ -886,6 +907,8 @@ def create_snapshot( def create_snapshot( sandbox_id: str, name: Optional[str] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -895,6 +918,8 @@ def create_snapshot( :param sandbox_id: Sandbox ID :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + :param mode: What the snapshot persists. With `"filesystem"`, only the filesystem is persisted: the snapshot is smaller and faster to take, and sandboxes created from it cold-boot (start fresh from disk) instead of restoring memory, so they begin without the running processes, in-memory state and open connections of the source sandbox. The source sandbox keeps running either way. Defaults to `"full"` (memory and filesystem). + :raises SandboxException: when the API refuses a filesystem-only snapshot (``mode="filesystem"``). ``status_code`` 400 means the feature is not enabled for the team (``snapshot_filesystem_only_disabled``); 409 means the sandbox's node runs an orchestrator that predates the option (``snapshot_filesystem_only_unsupported_node``). A full memory snapshot still works in both cases; for 409, pause and resume the sandbox and retry. :return: Snapshot information including the snapshot ID and names """ @@ -904,6 +929,8 @@ def create_snapshot( def create_snapshot( self, name: Optional[str] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: """ @@ -916,12 +943,15 @@ def create_snapshot( Use the returned `snapshot_id` with `Sandbox.create(snapshot_id)` to create a new sandbox from the snapshot. :param name: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + :param mode: What the snapshot persists. With `"filesystem"`, only the filesystem is persisted: the snapshot is smaller and faster to take, and sandboxes created from it cold-boot (start fresh from disk) instead of restoring memory, so they begin without the running processes, in-memory state and open connections of the source sandbox. The source sandbox keeps running either way. Defaults to `"full"` (memory and filesystem). + :raises SandboxException: when the API refuses a filesystem-only snapshot (``mode="filesystem"``). ``status_code`` 400 means the feature is not enabled for the team (``snapshot_filesystem_only_disabled``); 409 means the sandbox's node runs an orchestrator that predates the option (``snapshot_filesystem_only_unsupported_node``). A full memory snapshot still works in both cases; for 409, pause and resume the sandbox and retry. :return: Snapshot information including the snapshot ID and names """ return SandboxApi._cls_create_snapshot( sandbox_id=self.sandbox_id, name=name, + mode=mode, **self.connection_config.get_api_params(**opts), ) diff --git a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py index 013d65f941..8f4db3e890 100644 --- a/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py +++ b/packages/python-sdk/e2b/sandbox_sync/sandbox_api.py @@ -55,7 +55,9 @@ SandboxNetworkOpts, SandboxNetworkUpdate, SandboxOnResume, + SnapshotMode, resolve_connect_memory, + resolve_snapshot_memory, SandboxQuery, SnapshotInfo, build_iam_config, @@ -424,6 +426,8 @@ def _cls_create_snapshot( cls, sandbox_id: str, name: Optional[str] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> SnapshotInfo: config = ConnectionConfig(**cls._resolve_api_params(**opts)) @@ -432,7 +436,10 @@ def _cls_create_snapshot( res = post_sandboxes_sandbox_id_snapshots.sync_detailed( sandbox_id, client=api_client, - body=SandboxSnapshotRequest(name=name if name else UNSET), + body=SandboxSnapshotRequest( + name=name if name else UNSET, + memory=resolve_snapshot_memory("mode", mode), + ), ) if res.status_code == 404: @@ -479,6 +486,8 @@ def _cls_pause( cls, sandbox_id: str, keep_memory: Optional[bool] = None, + *, + mode: Optional[SnapshotMode] = None, **opts: Unpack[ApiParams], ) -> bool: config = ConnectionConfig(**cls._resolve_api_params(**opts)) @@ -488,7 +497,7 @@ def _cls_pause( sandbox_id, client=api_client, body=SandboxPauseRequest( - memory=keep_memory if keep_memory is not None else UNSET + memory=resolve_snapshot_memory("mode", mode, keep_memory) ), ) diff --git a/packages/python-sdk/tests/async/sandbox_async/test_create.py b/packages/python-sdk/tests/async/sandbox_async/test_create.py index 8ab73e8040..21f07f8863 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_create.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_create.py @@ -105,14 +105,14 @@ async def test_invalid_on_timeout_type_does_not_crash(async_sandbox_factory): @pytest.mark.skip_debug() -async def test_keep_memory_none_defaults_to_full_memory(async_sandbox_factory): - # An explicit None keep_memory must default to full memory (not filesystem-only): +async def test_mode_none_defaults_to_full_memory(async_sandbox_factory): + # An explicit None mode must default to full memory (not filesystem-only): # the timeout auto-pause then resumes the SAME sandbox in place (memory restore), # so the boot id is unchanged. A changed boot id would mean None was wrongly # treated as filesystem-only (cold boot). sbx = await async_sandbox_factory( timeout=60, - lifecycle={"on_timeout": {"action": "pause", "keep_memory": None}}, + lifecycle={"on_timeout": {"action": "pause", "mode": None}}, ) boot_before = (await sbx.files.read("/proc/sys/kernel/random/boot_id")).strip() @@ -132,11 +132,11 @@ async def test_keep_memory_none_defaults_to_full_memory(async_sandbox_factory): @pytest.mark.skip_debug() @pytest.mark.timeout(270) async def test_auto_pause_filesystem_only_reboots(async_sandbox_factory): - # keep_memory=False makes the timeout auto-pause filesystem-only, so resuming + # mode="filesystem" makes the timeout auto-pause filesystem-only, so resuming # cold-boots the sandbox from disk. sandbox = await async_sandbox_factory( timeout=3, - lifecycle={"on_timeout": {"action": "pause", "keep_memory": False}}, + lifecycle={"on_timeout": {"action": "pause", "mode": "filesystem"}}, ) marker = "auto-pause-fs-only" diff --git a/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py index 086d99d751..23534f6832 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_api.py @@ -197,3 +197,51 @@ async def test_create_snapshot_class_method(async_sandbox: AsyncSandbox): assert len(snapshot.snapshot_id) > 0 await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) + + +async def _boot_id(sandbox: AsyncSandbox) -> str: + # Kernel boot id: it changes only across a real (cold) boot. Read via a + # command, not files.read: envd serves procfs files as an empty 200 + # because it sizes them by stat. + result = await sandbox.commands.run("cat /proc/sys/kernel/random/boot_id") + return result.stdout.strip() + + +@pytest.mark.skip_debug() +async def test_create_filesystem_only_snapshot(async_sandbox: AsyncSandbox): + test_content = "filesystem-only snapshot content" + await async_sandbox.files.write("/home/user/fs-only.txt", test_content) + source_boot = await _boot_id(async_sandbox) + assert source_boot + + try: + snapshot = await async_sandbox.create_snapshot(mode="filesystem") + except SandboxException as error: + # The API answers 400 while filesystem-only snapshots are not enabled + # for the team; there is nothing to prove in that environment. + if error.status_code == 400 and "not enabled for this team" in str(error): + pytest.skip("filesystem-only snapshots are not enabled for this team") + raise + assert snapshot.snapshot_id + + try: + # The source sandbox keeps running and was not rebooted. + assert await async_sandbox.is_running() + assert await async_sandbox.files.read("/home/user/fs-only.txt") == test_content + assert await _boot_id(async_sandbox) == source_boot + + # A sandbox created from it has the files and a fresh boot id: it + # cold-booted instead of restoring memory. A memory snapshot would + # carry the source's boot id over, so this is what tells them apart. + new_sandbox = await AsyncSandbox.create(snapshot.snapshot_id) + try: + assert ( + await new_sandbox.files.read("/home/user/fs-only.txt") == test_content + ) + new_boot = await _boot_id(new_sandbox) + assert new_boot + assert new_boot != source_boot + finally: + await new_sandbox.kill() + finally: + await AsyncSandbox.delete_snapshot(snapshot.snapshot_id) diff --git a/packages/python-sdk/tests/async/sandbox_async/test_snapshot_filesystem_only.py b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_filesystem_only.py index 8f6acddb58..7a1ecfacda 100644 --- a/packages/python-sdk/tests/async/sandbox_async/test_snapshot_filesystem_only.py +++ b/packages/python-sdk/tests/async/sandbox_async/test_snapshot_filesystem_only.py @@ -13,7 +13,7 @@ async def test_pause_filesystem_only(async_sandbox: AsyncSandbox): ).strip() # Filesystem-only pause: only the rootfs is persisted, no memory snapshot. - assert await async_sandbox.pause(keep_memory=False, request_timeout=120) + assert await async_sandbox.pause(mode="filesystem", request_timeout=120) assert not await async_sandbox.is_running() # Resuming a filesystem-only snapshot cold-boots (reboots) from the rootfs. diff --git a/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py b/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py index 2c1c3678a3..6945e35f4e 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py +++ b/packages/python-sdk/tests/shared/sandbox/test_api_defaults.py @@ -10,6 +10,7 @@ post_v_2_sandboxes_sandbox_id_connect, post_sandboxes_sandbox_id_fork, post_sandboxes_sandbox_id_pause, + post_sandboxes_sandbox_id_snapshots, ) from e2b.api.client.models import Sandbox as SandboxModel @@ -204,32 +205,106 @@ async def _async_pause_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, An return request.call_args.kwargs["body"].to_dict() -def test_pause_omits_memory_when_keep_memory_unset(monkeypatch, test_api_key): +MODE_MEMORY = [("filesystem", False), ("full", True)] + + +def test_pause_omits_memory_when_mode_unset(monkeypatch, test_api_key): body = _sync_pause_body(monkeypatch, test_api_key) assert "memory" not in body -def test_pause_sends_explicit_keep_memory(monkeypatch, test_api_key): +@pytest.mark.parametrize("mode, memory", MODE_MEMORY) +def test_pause_maps_mode_to_memory(monkeypatch, test_api_key, mode, memory): + body = _sync_pause_body(monkeypatch, test_api_key, mode=mode) + + assert body["memory"] is memory + + +def test_pause_still_sends_the_deprecated_keep_memory(monkeypatch, test_api_key): body = _sync_pause_body(monkeypatch, test_api_key, keep_memory=False) assert body["memory"] is False -async def test_async_pause_omits_memory_when_keep_memory_unset( - monkeypatch, test_api_key -): +@pytest.mark.parametrize( + "kwargs, match", + [ + ({"mode": "full", "keep_memory": True}, "not both"), + ({"mode": "memory"}, "mode must be one of: full, filesystem"), + ], +) +def test_pause_rejects_an_invalid_mode(monkeypatch, test_api_key, kwargs, match): + request = Mock() + monkeypatch.setattr(post_sandboxes_sandbox_id_pause, "sync_detailed", request) + + with pytest.raises(InvalidArgumentException, match=match): + Sandbox.pause("sbx-test", api_key=test_api_key, **kwargs) + + request.assert_not_called() + + +async def test_async_pause_omits_memory_when_mode_unset(monkeypatch, test_api_key): body = await _async_pause_body(monkeypatch, test_api_key) assert "memory" not in body -async def test_async_pause_sends_explicit_keep_memory(monkeypatch, test_api_key): +@pytest.mark.parametrize("mode, memory", MODE_MEMORY) +async def test_async_pause_maps_mode_to_memory(monkeypatch, test_api_key, mode, memory): + body = await _async_pause_body(monkeypatch, test_api_key, mode=mode) + + assert body["memory"] is memory + + +async def test_async_pause_still_sends_the_deprecated_keep_memory( + monkeypatch, test_api_key +): body = await _async_pause_body(monkeypatch, test_api_key, keep_memory=False) assert body["memory"] is False +def _snapshot_response(): + return SimpleNamespace( + status_code=201, + parsed=SimpleNamespace(snapshot_id="snap-test", names=[]), + ) + + +def test_create_snapshot_omits_memory_when_mode_unset(monkeypatch, test_api_key): + request = Mock(return_value=_snapshot_response()) + monkeypatch.setattr(post_sandboxes_sandbox_id_snapshots, "sync_detailed", request) + + Sandbox.create_snapshot("sbx-test", api_key=test_api_key) + + assert "memory" not in request.call_args.kwargs["body"].to_dict() + + +@pytest.mark.parametrize("mode, memory", MODE_MEMORY) +def test_create_snapshot_maps_mode_to_memory(monkeypatch, test_api_key, mode, memory): + request = Mock(return_value=_snapshot_response()) + monkeypatch.setattr(post_sandboxes_sandbox_id_snapshots, "sync_detailed", request) + + Sandbox.create_snapshot("sbx-test", mode=mode, api_key=test_api_key) + + assert request.call_args.kwargs["body"].to_dict()["memory"] is memory + + +@pytest.mark.parametrize("mode, memory", MODE_MEMORY) +async def test_async_create_snapshot_maps_mode_to_memory( + monkeypatch, test_api_key, mode, memory +): + request = AsyncMock(return_value=_snapshot_response()) + monkeypatch.setattr( + post_sandboxes_sandbox_id_snapshots, "asyncio_detailed", request + ) + + await AsyncSandbox.create_snapshot("sbx-test", mode=mode, api_key=test_api_key) + + assert request.call_args.kwargs["body"].to_dict()["memory"] is memory + + def _sync_connect_body(monkeypatch, api_key: str, **kwargs) -> Dict[str, Any]: request = Mock(return_value=_created_sandbox()) monkeypatch.setattr(post_v_2_sandboxes_sandbox_id_connect, "sync_detailed", request) diff --git a/packages/python-sdk/tests/shared/sandbox/test_lifecycle_request.py b/packages/python-sdk/tests/shared/sandbox/test_lifecycle_request.py index d208edcafd..20a6292744 100644 --- a/packages/python-sdk/tests/shared/sandbox/test_lifecycle_request.py +++ b/packages/python-sdk/tests/shared/sandbox/test_lifecycle_request.py @@ -80,7 +80,7 @@ async def test_async_create_sends_auto_pause_only_when_configured( assert "autoPauseMemory" not in body -def test_create_omits_auto_pause_memory_when_pause_omits_keep_memory( +def test_create_omits_auto_pause_memory_when_pause_omits_mode( monkeypatch, test_api_key ): body = _sync_request_body( @@ -99,7 +99,7 @@ def test_create_sends_the_pause_snapshot_kind_alongside_auto_pause( body = _sync_request_body( monkeypatch, test_api_key, - {"on_timeout": {"action": "pause", "keep_memory": False}}, + {"on_timeout": {"action": "pause", "mode": "filesystem"}}, ) assert body["autoPause"] is True @@ -108,13 +108,24 @@ def test_create_sends_the_pause_snapshot_kind_alongside_auto_pause( body = _sync_request_body( monkeypatch, test_api_key, - {"on_timeout": {"action": "pause", "keep_memory": True}}, + {"on_timeout": {"action": "pause", "mode": "full"}}, ) assert body["autoPause"] is True assert body["autoPauseMemory"] is True +def test_create_still_sends_the_deprecated_keep_memory(monkeypatch, test_api_key): + body = _sync_request_body( + monkeypatch, + test_api_key, + {"on_timeout": {"action": "pause", "keep_memory": False}}, + ) + + assert body["autoPause"] is True + assert body["autoPauseMemory"] is False + + @pytest.mark.parametrize( "lifecycle", [ @@ -150,15 +161,31 @@ async def test_async_create_rejects_auto_resume_without_a_timeout_action( INVALID_LIFECYCLES = [ # A filesystem-only auto-pause snapshot can only be resumed explicitly. pytest.param( - {"on_timeout": {"action": "pause", "keep_memory": False}, "auto_resume": True}, + {"on_timeout": {"action": "pause", "mode": "filesystem"}, "auto_resume": True}, id="filesystem-only-pause-with-auto-resume", ), + pytest.param( + {"on_timeout": {"action": "pause", "keep_memory": False}, "auto_resume": True}, + id="keep-memory-false-pause-with-auto-resume", + ), # The discriminated union forbids this at type-check time; the runtime # guard covers callers that bypass the type. + pytest.param( + cast(Any, {"on_timeout": {"action": "kill", "mode": "filesystem"}}), + id="mode-with-kill", + ), pytest.param( cast(Any, {"on_timeout": {"action": "kill", "keep_memory": False}}), id="keep-memory-with-kill", ), + pytest.param( + cast(Any, {"on_timeout": {"action": "pause", "mode": "memory"}}), + id="unknown-mode", + ), + pytest.param( + {"on_timeout": {"action": "pause", "mode": "full", "keep_memory": True}}, + id="mode-with-keep-memory", + ), ] diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py index 36843b0e5f..5a28bf73c8 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_create.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_create.py @@ -100,14 +100,14 @@ def test_invalid_on_timeout_type_does_not_crash(sandbox_factory): @pytest.mark.skip_debug() -def test_keep_memory_none_defaults_to_full_memory(sandbox_factory): - # An explicit None keep_memory must default to full memory (not filesystem-only): +def test_mode_none_defaults_to_full_memory(sandbox_factory): + # An explicit None mode must default to full memory (not filesystem-only): # the timeout auto-pause then resumes the SAME sandbox in place (memory restore), # so the boot id is unchanged. A changed boot id would mean None was wrongly # treated as filesystem-only (cold boot). sbx = sandbox_factory( timeout=60, - lifecycle={"on_timeout": {"action": "pause", "keep_memory": None}}, + lifecycle={"on_timeout": {"action": "pause", "mode": None}}, ) boot_before = sbx.files.read("/proc/sys/kernel/random/boot_id").strip() @@ -127,11 +127,11 @@ def test_keep_memory_none_defaults_to_full_memory(sandbox_factory): @pytest.mark.skip_debug() @pytest.mark.timeout(270) def test_auto_pause_filesystem_only_reboots(sandbox_factory): - # keep_memory=False makes the timeout auto-pause filesystem-only, so resuming + # mode="filesystem" makes the timeout auto-pause filesystem-only, so resuming # cold-boots the sandbox from disk. sandbox = sandbox_factory( timeout=3, - lifecycle={"on_timeout": {"action": "pause", "keep_memory": False}}, + lifecycle={"on_timeout": {"action": "pause", "mode": "filesystem"}}, ) marker = "auto-pause-fs-only" diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py index e8d00d3dd1..45a9b4ce4a 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_api.py @@ -191,3 +191,48 @@ def test_create_snapshot_class_method(sandbox: Sandbox): assert len(snapshot.snapshot_id) > 0 Sandbox.delete_snapshot(snapshot.snapshot_id) + + +def _boot_id(sandbox: Sandbox) -> str: + # Kernel boot id: it changes only across a real (cold) boot. Read via a + # command, not files.read: envd serves procfs files as an empty 200 + # because it sizes them by stat. + return sandbox.commands.run("cat /proc/sys/kernel/random/boot_id").stdout.strip() + + +@pytest.mark.skip_debug() +def test_create_filesystem_only_snapshot(sandbox: Sandbox): + test_content = "filesystem-only snapshot content" + sandbox.files.write("/home/user/fs-only.txt", test_content) + source_boot = _boot_id(sandbox) + assert source_boot + + try: + snapshot = sandbox.create_snapshot(mode="filesystem") + except SandboxException as error: + # The API answers 400 while filesystem-only snapshots are not enabled + # for the team; there is nothing to prove in that environment. + if error.status_code == 400 and "not enabled for this team" in str(error): + pytest.skip("filesystem-only snapshots are not enabled for this team") + raise + assert snapshot.snapshot_id + + try: + # The source sandbox keeps running and was not rebooted. + assert sandbox.is_running() + assert sandbox.files.read("/home/user/fs-only.txt") == test_content + assert _boot_id(sandbox) == source_boot + + # A sandbox created from it has the files and a fresh boot id: it + # cold-booted instead of restoring memory. A memory snapshot would + # carry the source's boot id over, so this is what tells them apart. + new_sandbox = Sandbox.create(snapshot.snapshot_id) + try: + assert new_sandbox.files.read("/home/user/fs-only.txt") == test_content + new_boot = _boot_id(new_sandbox) + assert new_boot + assert new_boot != source_boot + finally: + new_sandbox.kill() + finally: + Sandbox.delete_snapshot(snapshot.snapshot_id) diff --git a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_filesystem_only.py b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_filesystem_only.py index 85851038c8..dd688cbf7b 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_filesystem_only.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/test_snapshot_filesystem_only.py @@ -11,7 +11,7 @@ def test_pause_filesystem_only(sandbox: Sandbox): boot_before = sandbox.files.read("/proc/sys/kernel/random/boot_id").strip() # Filesystem-only pause: only the rootfs is persisted, no memory snapshot. - assert sandbox.pause(keep_memory=False, request_timeout=120) + assert sandbox.pause(mode="filesystem", request_timeout=120) assert not sandbox.is_running() # Resuming a filesystem-only snapshot cold-boots (reboots) from the rootfs. diff --git a/packages/python-sdk/tests/test_filesystem_models.py b/packages/python-sdk/tests/test_filesystem_models.py index 96c540a41c..33bf4909e1 100644 --- a/packages/python-sdk/tests/test_filesystem_models.py +++ b/packages/python-sdk/tests/test_filesystem_models.py @@ -149,3 +149,33 @@ def test_multipart_body_is_streamed_only_for_binary_file_like_entries(): {"path": "b.bin", "data": io.BytesIO(b"bytes")}, ] ) + + +def test_map_entry_info_reports_symlink_when_envd_marks_the_entry(): + # Newer envd puts the target's type in `type` and marks the link with `is_symlink`. + def entry(file_type, is_symlink: bool): + return filesystem_pb.EntryInfo( + name="link", + type=file_type, + path="/home/user/link", + size=0, + mode=0o777, + permissions="lrwxrwxrwx", + owner="user", + group="user", + symlink_target="/home/user/releases/v1", + is_symlink=is_symlink, + ) + + to_dir = map_entry_info(entry(filesystem_pb.FileType.DIRECTORY, True)) + assert to_dir.type is FileType.SYMLINK + assert to_dir.symlink_target == "/home/user/releases/v1" + assert ( + map_entry_info(entry(filesystem_pb.FileType.FILE, True)).type + is FileType.SYMLINK + ) + # Without the flag the target type stands, as for a plain directory. + assert ( + map_entry_info(entry(filesystem_pb.FileType.DIRECTORY, False)).type + is FileType.DIR + ) diff --git a/spec/envd/envd.yaml b/spec/envd/envd.yaml index d385e6d587..9e1a304ef1 100644 --- a/spec/envd/envd.yaml +++ b/spec/envd/envd.yaml @@ -535,18 +535,32 @@ components: mem_cache: type: integer description: Cached memory (page cache) in bytes - mem_total_mib: - type: integer - description: Total virtual memory in MiB - mem_used_mib: - type: integer - description: Used virtual memory in MiB disk_used: type: integer description: Used disk space in bytes disk_total: type: integer description: Total disk space in bytes + oom_kills: + type: array + description: The latest 32 out-of-memory kills since the guest booted, oldest first. Reading does not consume them. Empty when there are none. Omitted when not known (while envd is still reading the kernel log, when it can't be read or envd stopped reading it, and outside Firecracker). + x-go-type-skip-optional-pointer: false + items: + $ref: "#/components/schemas/OOMKill" + OOMKill: + type: object + description: A process the guest kernel killed for running out of memory + required: + - seq + - process + properties: + seq: + type: integer + format: int64 + description: Kernel log sequence number of the kill record. It only grows, across pauses and envd restarts. + process: + type: string + description: Name of the killed process, or Unknown if the record doesn't show it FreezeResult: type: object description: Per-call statistics from a pre-pause workload freeze diff --git a/spec/envd/filesystem/filesystem.proto b/spec/envd/filesystem/filesystem.proto index 7c4c43174c..5325d85f47 100644 --- a/spec/envd/filesystem/filesystem.proto +++ b/spec/envd/filesystem/filesystem.proto @@ -66,6 +66,8 @@ message EntryInfo { // Keys live under the `user.e2b.` xattr namespace; the prefix is stripped here. // Plain `user.*` xattrs written by other tooling are not reflected. map metadata = 11; + // True when the entry itself is a symlink; type then describes the symlink's target. + bool is_symlink = 12; } enum FileType { diff --git a/spec/openapi.yml b/spec/openapi.yml index 7dc37ed62f..f293777631 100644 --- a/spec/openapi.yml +++ b/spec/openapi.yml @@ -75,6 +75,7 @@ components: name: teamID in: path required: true + description: Identifier of the team, as its UUID or its public project ID (prj_) schema: type: string nodeID: @@ -1133,6 +1134,15 @@ components: name: type: string description: Optional name for the snapshot template. If a snapshot template with this name already exists, a new build will be assigned to the existing template instead of creating a new one. + memory: + type: boolean + description: >- + Whether to capture a full memory snapshot. When false, only the + filesystem is persisted: the snapshot is smaller and faster to take, + and sandboxes created from it cold-boot (start fresh from disk) + instead of restoring memory, so they begin without the source + sandbox's running processes, in-memory state, and open connections. + The source sandbox keeps running in both cases. Defaults to true. SandboxPauseRequest: type: object @@ -1521,7 +1531,7 @@ components: teamID: deprecated: true type: string - description: Identifier of the team + description: Identifier of the team, as its UUID or its public project ID (prj_) cpuCount: $ref: "#/components/schemas/CPUCount" memoryMB: @@ -1904,6 +1914,8 @@ components: - status - statusChangedAt - sandboxCount + - outstandingWork + - maxSandboxes - metrics - createSuccesses - createFails @@ -1942,12 +1954,12 @@ components: maxSandboxes: type: integer format: int64 - description: Node-scoped configured sandbox admission limit. Nonpositive values reject creation. Omitted when unknown or not an orchestrator. + description: Cached node-scoped sandbox admission limit. Nonpositive values reject creation. outstandingWork: type: integer format: uint64 minimum: 0 - description: Observed work holds on the node. Omitted when unknown; zero does not authorize deletion. + description: Cached count of work holds on the node. Zero means idle or not yet reported; it does not by itself authorize deletion. metrics: $ref: "#/components/schemas/NodeMetrics" createSuccesses: @@ -1971,6 +1983,8 @@ components: - status - statusChangedAt - sandboxCount + - outstandingWork + - maxSandboxes - createSuccesses - createFails - version @@ -2008,12 +2022,12 @@ components: maxSandboxes: type: integer format: int64 - description: Node-scoped configured sandbox admission limit. Nonpositive values reject creation. Omitted when unknown or not an orchestrator. + description: Cached node-scoped sandbox admission limit. Nonpositive values reject creation. outstandingWork: type: integer format: uint64 minimum: 0 - description: Observed work holds on the node. Omitted when unknown; zero does not authorize deletion. + description: Cached count of work holds on the node. Zero means idle or not yet reported; it does not by itself authorize deletion. metrics: $ref: "#/components/schemas/NodeMetrics" createSuccesses: @@ -2180,7 +2194,8 @@ components: description: >- Machine-readable semantic error code. Not a closed set; initial values: sandbox_capacity_unavailable, sandbox_placement_timeout, - sandbox_no_compatible_node, sandbox_create_failed, internal_server_error. + sandbox_no_compatible_node, sandbox_create_failed, internal_server_error, + secret_limit_reached. message: type: string description: Error @@ -3755,7 +3770,7 @@ paths: name: teamID schema: type: string - description: Identifier of the team + description: Identifier of the team, as its UUID or its public project ID (prj_) - $ref: "#/components/parameters/paginationNextToken" - $ref: "#/components/parameters/paginationLimit" responses: @@ -3842,7 +3857,7 @@ paths: name: teamID schema: type: string - description: Identifier of the team + description: Identifier of the team, as its UUID or its public project ID (prj_) responses: "200": description: Successfully returned all templates @@ -4843,7 +4858,20 @@ paths: "404": $ref: "#/components/responses/404" "409": - $ref: "#/components/responses/409" + description: >- + Secret name conflict or project live-secret limit reached. A quota + response has error_code secret_limit_reached and, when reported by + the backend, the effective cap in message. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + secretLimitReached: + value: + code: 409 + message: Project has reached its limit of 50 live secrets + error_code: secret_limit_reached "429": $ref: "#/components/responses/429" "500": diff --git a/spec/runtime-ref b/spec/runtime-ref index d6288fb797..abf34cf2b2 100644 --- a/spec/runtime-ref +++ b/spec/runtime-ref @@ -1 +1 @@ -f5dc6426ef45b6b286ff60c11fc6bba7e00db95e +90ad3bdbb1a4ad78161463dc90b66b4f8303897b