letify 2.8.1__tar.gz → 2.8.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {letify-2.8.1 → letify-2.8.2}/PKG-INFO +1 -1
- {letify-2.8.1 → letify-2.8.2}/docs/SPEC.md +18 -1
- {letify-2.8.1 → letify-2.8.2}/letify/__init__.py +1 -1
- {letify-2.8.1 → letify-2.8.2}/letify/protocol/worker.py +102 -1
- {letify-2.8.1 → letify-2.8.2}/letify/runtime/session.py +183 -52
- {letify-2.8.1 → letify-2.8.2}/letify/store/volume.py +16 -9
- {letify-2.8.1 → letify-2.8.2}/letify/transport/nat.py +15 -1
- {letify-2.8.1 → letify-2.8.2}/letify/transport/strategies.py +101 -15
- {letify-2.8.1 → letify-2.8.2}/letify-core/Cargo.lock +3 -3
- {letify-2.8.1 → letify-2.8.2}/letify-core/Cargo.toml +1 -1
- {letify-2.8.1 → letify-2.8.2}/letify-ext/package-lock.json +2 -2
- {letify-2.8.1 → letify-2.8.2}/letify-ext/package.json +1 -1
- {letify-2.8.1 → letify-2.8.2}/pyproject.toml +1 -1
- {letify-2.8.1 → letify-2.8.2}/tests/conftest.py +4 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_drive_store.py +108 -1
- {letify-2.8.1 → letify-2.8.2}/tests/test_strategies.py +125 -0
- {letify-2.8.1 → letify-2.8.2}/.github/workflows/ci.yml +0 -0
- {letify-2.8.1 → letify-2.8.2}/.github/workflows/docs.yml +0 -0
- {letify-2.8.1 → letify-2.8.2}/.github/workflows/publish.yml +0 -0
- {letify-2.8.1 → letify-2.8.2}/.gitignore +0 -0
- {letify-2.8.1 → letify-2.8.2}/.researchtree +0 -0
- {letify-2.8.1 → letify-2.8.2}/CLAUDE.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/LICENSE +0 -0
- {letify-2.8.1 → letify-2.8.2}/PROJECT.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/README.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/COMPONENT.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/INTENT.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/NETWORK.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/01-getting-started.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/02-providers.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/03-execution-modes.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/04-environments-and-data.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/05-concurrency.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/06-cost.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/07-troubleshooting.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/08-releasing.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/README.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/01-/354/213/234/354/236/221/355/225/230/352/270/260.md" +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/02-/355/224/204/353/241/234/353/260/224/354/235/264/353/215/224.md" +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/03-/354/213/244/355/226/211-/353/260/251/354/213/235.md" +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/04-/355/231/230/352/262/275/352/263/274-/353/215/260/354/235/264/355/204/260.md" +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/05-/353/217/231/354/213/234-/354/213/244/355/226/211.md" +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/06-/353/271/204/354/232/251.md" +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/07-/353/254/270/354/240/234-/355/225/264/352/262/260.md" +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/08-/353/246/264/353/246/254/354/212/244.md" +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/guide/ko/README.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/locales/README_ko.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/docs/mkdocs.yml +0 -0
- {letify-2.8.1 → letify-2.8.2}/examples/README.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/.letify.example +0 -0
- {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/00_smoke.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/01_sweep.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/02_resume.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/03_watch.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/README.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/README_ko.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/common.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/examples/nvfp4-lora/recipe.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/cli.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/config/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/config/inventory.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/config/login.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/config/schema.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/config/secrets.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/config/writer.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/declare/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/declare/cache.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/declare/env.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/declare/function.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/declare/instance.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/declare/reduce.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/declare/replicas.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/errors.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/install.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/launcher.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/paths.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/protocol/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/protocol/codec.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/protocol/driver.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/protocol/handle.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/protocol/wire.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/base.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/colab.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/colab_files.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/colab_keepalive.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/elice.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/kaggle.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/kaggle_adapter.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/local.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/modal.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/modal_adapter.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/naming.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/shell.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/tunnel.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/providers/usage.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/capability.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/client.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/cuda.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/executor.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/frames.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/guard.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/tensor.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/trace.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/device/value.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/loader.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/remoting/probe.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/render.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/runtime/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/runtime/bootstrap.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/runtime/broker.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/runtime/channel.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/runtime/lease.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/runtime/pool.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/runtime/telemetry.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/backends/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/backends/drive.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/backends/filesystem.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/backends/google_auth.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/backends/layout.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/backends/objects.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/cas.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/handoff.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/pathdata.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/store/sendorder.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/stubs.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/tools.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/__init__.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/agent.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/announce.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/link.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/pipeline.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/portmap.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/probe.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/quic.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/rendezvous.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/setup.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify/transport/sshopts.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/.gitignore +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/build.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-agent/Cargo.toml +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-agent/src/driver.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-agent/src/main.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/Cargo.toml +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/client.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/exports.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/image.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/lib.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/status.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-driver/src/table.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-quic/Cargo.toml +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-quic/src/main.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-wire/Cargo.toml +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-wire/src/lib.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/letify-wire/tests/throughput.rs +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-core/rust-toolchain.toml +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/.vscodeignore +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/LICENSE +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/README.md +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/media/icon.svg +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/src/cli.ts +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/src/extension.ts +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/src/history.ts +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/src/model.ts +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/src/poll.ts +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/src/render.ts +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/test/cli.test.ts +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/test/manifest.test.ts +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/test/model.test.ts +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/test/regressions.test.ts +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify-ext/tsconfig.json +0 -0
- {letify-2.8.1 → letify-2.8.2}/letify_providers.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/scripts/sync-main.sh +0 -0
- {letify-2.8.1 → letify-2.8.2}/scripts/version.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/fake_kaggle_adapter.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/fake_modal_adapter.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_cli.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_cli_output.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_colab_keepalive.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_config.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_core.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_data.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_data_streaming.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_declare.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_device.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_distributed.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_drive_handoff.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_editor_sessions.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_elice.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_frames.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_install.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_kaggle.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_kaggle_cookie.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_kaggle_link.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_link_log.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_notebook.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_parallel_placement.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_paths.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_portmap.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_protocol.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_providers.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_quic.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_remoting.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_render.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_runtime.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_ship.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_store.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_stubs.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_transport.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_tunnel_setup.py +0 -0
- {letify-2.8.1 → letify-2.8.2}/tests/test_version.py +0 -0
|
@@ -1275,7 +1275,9 @@ Letify keeps its files in a folder named `letify` at the root of that Drive, one
|
|
|
1275
1275
|
|
|
1276
1276
|
An ephemeral provider uses the Drive store for its environment archive when the machine declares one and the account sets no `bucket`, which keeps precedence. A provider whose environment survives the session on its own disk or volume does not use it. An archive on Drive is an uncompressed tar, because compression was most of the cost: on a Kaggle runtime, packing a 5.5 GB environment as tar.gz and uploading it took about 12 minutes, against an upload of roughly 77 s for the uncompressed tree at the measured rate.
|
|
1277
1277
|
|
|
1278
|
-
Whether an archive is worth it is measured, not assumed, because a provider with a fast package index can build faster than it restores. On a Kaggle runtime the same 5.5 GB environment built in 35.4 s and restored from Drive in about 90 s. So after a sync letify packs only when the build took
|
|
1278
|
+
Whether an archive is worth it is measured, not assumed, because a provider with a fast package index can build faster than it restores. On a Kaggle runtime the same 5.5 GB environment built in 35.4 s and restored from Drive in about 90 s. So after a sync letify packs only when the build took at least twice as long as the archive would take to restore, estimated as the project directory's size over 176 MiB/s, the slower Drive download measured, plus the same size over 170 MB/s, the extraction rate measured on a sandbox disk. The record beside the archive keeps the build seconds and, once a session has restored from it, the restore seconds that session measured; a later session restores only while the measured restore is shorter than the measured build, and syncs otherwise, saying `letify: <runtime>: syncing rather than restoring from Drive, because a restore took <s> s against a <s> s build`. The factor of two is there because packing has a cost of its own that the comparison leaves out: on a Kaggle runtime a 7.2 GB environment that built in 87.6 s against an estimated 81 s restore was packed, which would save about 6 s a session at the price of the pack.
|
|
1279
|
+
|
|
1280
|
+
The pack to Drive runs on the worker beside the session's calls, not before them. After the interpreter check the session starts the pack as a background job and returns, so the first call starts at once; the worker answers calls on its main thread while a separate thread writes the tar and uploads it, and the archive's ref and build record are written only when the upload completes. The upload session needs the archive's digest and size, so the worker reports them once the tar is written, and a client thread that checks every 2 s creates the resumable session and hands its URL to the worker, which then uploads; the client records and reports the result at the start and end of a call and at session close. A session that ends first leaves no ref, and the next session syncs and may pack again. While it runs the pack says nothing more after `caching the environment, <size> GB`; when it ends it says `letify: <runtime>: cached the environment, <size> GB in <s> s (<rate> MiB/s)`, or the Drive failure line above, on standard error of whichever call or session close observes it first. Closing the session waits up to 60 s for a pack still running and then abandons it, saying `letify: <runtime>: not caching the environment, because the session closed <s> s into the pack`.
|
|
1279
1281
|
|
|
1280
1282
|
Drive's own limits apply. The archive counts against the Google account's storage, which is the person's own plan. Google documents a daily upload limit of 750 GB per user for Google Workspace accounts, independent of the storage plan; an environment archive is a few gigabytes, so letify does not approach it. Standard use of the Drive API costs nothing, and Google documents that exceeding its request quota is planned to be charged to the project's billing account later in 2026; letify's requests are a handful per session against a quota of 325,000 units per user per minute.
|
|
1281
1283
|
|
|
@@ -1910,6 +1912,21 @@ It is opt-in because it needs the user's machine to accept inbound SSH and needs
|
|
|
1910
1912
|
|
|
1911
1913
|
The key is per session. letify generates a new ed25519 key pair for each session and appends the public key to the user's `~/.ssh/authorized_keys` with `restrict,port-forwarding,command="/bin/false"` and the comment `letify-session <alias>`. The private key reaches the remote side through the rendezvous. Closing the link removes the line. Before a new session's key is added, every line carrying the `letify-session` marker for that alias is removed, so a crashed session never leaves a key behind.
|
|
1912
1914
|
|
|
1915
|
+
#### Reverse SSH through a jump host <!-- id: reverse-ssh-jump -->
|
|
1916
|
+
|
|
1917
|
+
> `reverse_ssh` takes an optional `jump`, for a user's machine whose SSH server the internet reaches only through another server.
|
|
1918
|
+
|
|
1919
|
+
```toml
|
|
1920
|
+
[colab_pro_plus]
|
|
1921
|
+
reverse_ssh = { address = "168.188.127.175", port = 30008, user = "work", jump = { address = "jump.example.org", port = 20070, user = "root", key = "~/.ssh/id_jump" } }
|
|
1922
|
+
```
|
|
1923
|
+
|
|
1924
|
+
With `jump`, `address` and `port` are the user's SSH server as the jump host reaches it, and the remote side connects to `<user>@<address>:<port>` through `-o ProxyCommand="ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new -i <session key> -W %h:%p -p <jump port> <jump user>@<jump address>"` and forwards its port back with `-R` as before. It is a ProxyCommand rather than `-J`, because the options given for the main hop on the command line do not reach a `-J` hop.
|
|
1925
|
+
|
|
1926
|
+
The per-session key authorizes both hops. Besides the user's own `~/.ssh/authorized_keys`, letify appends the session's public key to the jump host's `~/.ssh/authorized_keys` with `restrict,port-forwarding,permitopen="<address>:<port>",command="/bin/false"` and the comment `letify-session <alias>`, so the key opens a connection to the user's SSH server and nothing else. letify reaches the jump host from the user's machine with `ssh -i <jump key> -p <jump port> <jump user>@<jump address>`, `BatchMode=yes`, and edits the file there with a shell command; `key` is optional and defaults to the SSH client's own keys. Closing the link removes that line, and before a new session's key is added every line carrying the `letify-session` marker for that alias is removed from the jump host too, as on the user's machine. The jump key never leaves the user's machine.
|
|
1927
|
+
|
|
1928
|
+
A jump host that letify cannot log in to, or whose `authorized_keys` it cannot edit, fails the strategy with `reverse_ssh: the jump host <user>@<address>:<port> refused letify (<reason>)`, before anything is sent to the remote side.
|
|
1929
|
+
|
|
1913
1930
|
### Colab <!-- id: colab-transport -->
|
|
1914
1931
|
|
|
1915
1932
|
> Colab is a `Shell` whose rendezvous is `colab exec`. It has no forward SSH, and its fallback is `colab exec` with the Colab file API.
|
|
@@ -2367,6 +2367,7 @@ def _op_push_blob(request):
|
|
|
2367
2367
|
|
|
2368
2368
|
url = request.pop("url")
|
|
2369
2369
|
headers = request.pop("headers", None) or {}
|
|
2370
|
+
cancel = request.pop("cancel", None)
|
|
2370
2371
|
path = _data_file(os.path.expanduser(request["dir"]), request["digest"])
|
|
2371
2372
|
size = os.path.getsize(path)
|
|
2372
2373
|
piece = int(request.get("piece") or (64 << 20))
|
|
@@ -2374,6 +2375,8 @@ def _op_push_blob(request):
|
|
|
2374
2375
|
with open(path, "rb") as handle:
|
|
2375
2376
|
offset = 0
|
|
2376
2377
|
while offset < size:
|
|
2378
|
+
if cancel is not None and cancel.is_set():
|
|
2379
|
+
raise RuntimeError("the upload was abandoned")
|
|
2377
2380
|
block = handle.read(piece)
|
|
2378
2381
|
end = offset + len(block)
|
|
2379
2382
|
span = "bytes %d-%d/%d" % (offset, end - 1, size)
|
|
@@ -2395,6 +2398,98 @@ def _op_push_blob(request):
|
|
|
2395
2398
|
return {"ok": True, "value": {"id": remote_id, "size": size}}
|
|
2396
2399
|
|
|
2397
2400
|
|
|
2401
|
+
#: The one environment pack running beside the session's calls, or None. Spec "Project
|
|
2402
|
+
#: data / environment archive": the pack is a background job on the worker.
|
|
2403
|
+
_PACK_JOB = {}
|
|
2404
|
+
|
|
2405
|
+
|
|
2406
|
+
def _pack_push_run(job, request):
|
|
2407
|
+
"""Pack a directory, wait for the upload target, upload it, and remove the staging dir.
|
|
2408
|
+
|
|
2409
|
+
Runs on its own thread, so the worker's main thread keeps answering calls. The state
|
|
2410
|
+
goes packing, packed, uploading, then done or failed. The staging directory is gone
|
|
2411
|
+
before done or failed is reported.
|
|
2412
|
+
"""
|
|
2413
|
+
import shutil
|
|
2414
|
+
|
|
2415
|
+
began = time.monotonic()
|
|
2416
|
+
directory = os.path.expanduser(request["dir"])
|
|
2417
|
+
result = None
|
|
2418
|
+
try:
|
|
2419
|
+
packed = _op_pack_blob(
|
|
2420
|
+
{"path": request["path"], "dir": request["dir"], "gzip": False}
|
|
2421
|
+
)["value"]
|
|
2422
|
+
job["digest"], job["size"] = packed["digest"], packed["size"]
|
|
2423
|
+
job["state"] = "packed"
|
|
2424
|
+
job["ready"].wait()
|
|
2425
|
+
target = job["target"]
|
|
2426
|
+
if target is None:
|
|
2427
|
+
raise RuntimeError("the pack was abandoned")
|
|
2428
|
+
job["state"] = "uploading"
|
|
2429
|
+
sent = _op_push_blob(
|
|
2430
|
+
{
|
|
2431
|
+
"dir": packed["dir"],
|
|
2432
|
+
"digest": packed["digest"],
|
|
2433
|
+
"url": target["url"],
|
|
2434
|
+
"headers": target.get("headers") or {},
|
|
2435
|
+
"piece": target.get("piece"),
|
|
2436
|
+
"cancel": job["cancel"],
|
|
2437
|
+
}
|
|
2438
|
+
)["value"]
|
|
2439
|
+
result = {"state": "done", "id": sent["id"], "size": sent["size"]}
|
|
2440
|
+
except BaseException as exc:
|
|
2441
|
+
result = {"state": "failed", "reason": str(exc) or type(exc).__name__}
|
|
2442
|
+
finally:
|
|
2443
|
+
shutil.rmtree(directory, ignore_errors=True)
|
|
2444
|
+
result["seconds"] = time.monotonic() - began
|
|
2445
|
+
job.update(result)
|
|
2446
|
+
|
|
2447
|
+
|
|
2448
|
+
def _op_pack_push_start(request):
|
|
2449
|
+
"""Start packing a directory on a thread of its own and return at once."""
|
|
2450
|
+
import threading
|
|
2451
|
+
|
|
2452
|
+
current = _PACK_JOB.get("job")
|
|
2453
|
+
if current is not None and current["state"] in ("packing", "packed", "uploading"):
|
|
2454
|
+
return {"ok": False, "error": "a pack is already running", "traceback": ""}
|
|
2455
|
+
job = {
|
|
2456
|
+
"state": "packing",
|
|
2457
|
+
"target": None,
|
|
2458
|
+
"ready": threading.Event(),
|
|
2459
|
+
"cancel": threading.Event(),
|
|
2460
|
+
}
|
|
2461
|
+
_PACK_JOB["job"] = job
|
|
2462
|
+
threading.Thread(target=_pack_push_run, args=(job, request), daemon=True).start()
|
|
2463
|
+
return {"ok": True, "value": None}
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
def _op_pack_push_target(request):
|
|
2467
|
+
"""Hand the running pack its upload session, or abandon it when ``url`` is None."""
|
|
2468
|
+
job = _PACK_JOB.get("job")
|
|
2469
|
+
if job is None:
|
|
2470
|
+
return {"ok": False, "error": "no pack is running", "traceback": ""}
|
|
2471
|
+
if request.get("url") is None:
|
|
2472
|
+
job["cancel"].set()
|
|
2473
|
+
job["target"] = None
|
|
2474
|
+
else:
|
|
2475
|
+
job["target"] = {
|
|
2476
|
+
"url": request["url"],
|
|
2477
|
+
"headers": request.get("headers") or {},
|
|
2478
|
+
"piece": request.get("piece"),
|
|
2479
|
+
}
|
|
2480
|
+
job["ready"].set()
|
|
2481
|
+
return {"ok": True, "value": None}
|
|
2482
|
+
|
|
2483
|
+
|
|
2484
|
+
def _op_pack_push_state(request):
|
|
2485
|
+
"""Report the pack: its state, and the digest, size, file id, seconds or reason it has."""
|
|
2486
|
+
job = _PACK_JOB.get("job")
|
|
2487
|
+
if job is None:
|
|
2488
|
+
return {"ok": True, "value": {"state": "none"}}
|
|
2489
|
+
keys = ("state", "digest", "size", "id", "seconds", "reason")
|
|
2490
|
+
return {"ok": True, "value": {key: job[key] for key in keys if key in job}}
|
|
2491
|
+
|
|
2492
|
+
|
|
2398
2493
|
def _op_place_blob(request):
|
|
2399
2494
|
"""Move a received file blob to its destination path, and unpack it when asked.
|
|
2400
2495
|
|
|
@@ -2539,6 +2634,9 @@ _OPS = {
|
|
|
2539
2634
|
"pack_dir": _op_pack_dir,
|
|
2540
2635
|
"pack_blob": _op_pack_blob,
|
|
2541
2636
|
"push_blob": _op_push_blob,
|
|
2637
|
+
"pack_push_start": _op_pack_push_start,
|
|
2638
|
+
"pack_push_target": _op_pack_push_target,
|
|
2639
|
+
"pack_push_state": _op_pack_push_state,
|
|
2542
2640
|
"place_blob": _op_place_blob,
|
|
2543
2641
|
"exec": _op_exec,
|
|
2544
2642
|
"eval": _op_eval,
|
|
@@ -2553,7 +2651,10 @@ _OPS = {
|
|
|
2553
2651
|
#: Answered by the frame reader at once, so they work while a call runs.
|
|
2554
2652
|
# ``data_failed`` is light because it must not queue behind the call it is about: the call
|
|
2555
2653
|
# is blocked on a blob the sender will never send, so a queued answer would deadlock.
|
|
2556
|
-
_LIGHT = (
|
|
2654
|
+
_LIGHT = (
|
|
2655
|
+
"stat", "lease", "reduce_peers", "drive_grant", "data_failed",
|
|
2656
|
+
"pack_push_state", "pack_push_target",
|
|
2657
|
+
)
|
|
2557
2658
|
|
|
2558
2659
|
#: Answered by several replies, the last one marked. These send their own replies.
|
|
2559
2660
|
_STREAMING = ("data_stream",)
|
|
@@ -22,6 +22,7 @@ import datetime
|
|
|
22
22
|
import json
|
|
23
23
|
import posixpath
|
|
24
24
|
import sys
|
|
25
|
+
import threading
|
|
25
26
|
import time
|
|
26
27
|
from collections.abc import Callable, Iterator
|
|
27
28
|
from dataclasses import dataclass, field
|
|
@@ -62,6 +63,16 @@ DRIVE_DOWNLOAD_BPS = 176 * 1024**2
|
|
|
62
63
|
#: 170 MB/s on a sandbox disk. Spec "Materializing into a runtime".
|
|
63
64
|
EXTRACT_BPS = 170_000_000
|
|
64
65
|
|
|
66
|
+
#: How many times the estimated restore a build must have taken before the environment is
|
|
67
|
+
#: packed to Drive. Spec "Materializing into a runtime": packing has a cost of its own.
|
|
68
|
+
DRIVE_PACK_FACTOR = 2
|
|
69
|
+
|
|
70
|
+
#: Seconds a closing session waits for a pack still running before it abandons it.
|
|
71
|
+
PACK_CLOSE_WAIT_S = 60.0
|
|
72
|
+
|
|
73
|
+
#: Seconds between the checks that hand a finished pack its upload session.
|
|
74
|
+
PACK_POLL_S = 2.0
|
|
75
|
+
|
|
65
76
|
|
|
66
77
|
def _against_the_build(size: int, rate: float, build_s: float) -> str:
|
|
67
78
|
"""The three numbers both cache decisions weigh, as they are reported.
|
|
@@ -87,6 +98,15 @@ def _sync_rather_than_restore(archive_bytes: int, rate: float, build_s: float) -
|
|
|
87
98
|
)
|
|
88
99
|
|
|
89
100
|
|
|
101
|
+
@dataclass
|
|
102
|
+
class _DrivePack:
|
|
103
|
+
"""A pack the worker is running for a Drive store, as this process tracks it."""
|
|
104
|
+
|
|
105
|
+
volume: Any
|
|
106
|
+
began: float = field(default_factory=time.monotonic)
|
|
107
|
+
target_sent: bool = False
|
|
108
|
+
|
|
109
|
+
|
|
90
110
|
@dataclass
|
|
91
111
|
class Runtime:
|
|
92
112
|
"""One live session on one provider."""
|
|
@@ -155,6 +175,11 @@ class Runtime:
|
|
|
155
175
|
#: Set once ``shutdown`` has run, so a second discard does nothing.
|
|
156
176
|
_shut_down: bool = field(default=False, repr=False)
|
|
157
177
|
|
|
178
|
+
#: The environment pack running on the worker, or None. Spec "Materializing into a
|
|
179
|
+
#: runtime": the pack to Drive runs beside the session's calls.
|
|
180
|
+
_pack: _DrivePack | None = field(default=None, repr=False)
|
|
181
|
+
_pack_lock: Any = field(default_factory=threading.RLock, repr=False)
|
|
182
|
+
|
|
158
183
|
@property
|
|
159
184
|
def key(self) -> str:
|
|
160
185
|
"""Pool key. Runtimes with equal keys are interchangeable."""
|
|
@@ -215,14 +240,12 @@ class Runtime:
|
|
|
215
240
|
+ (f", {size / 1_000_000_000:.1f} GB" if size else "")
|
|
216
241
|
)
|
|
217
242
|
volume = self._environment_volumes()[0]
|
|
218
|
-
|
|
243
|
+
if volume.store.backend.moves_on_runtime:
|
|
244
|
+
self._start_drive_pack(volume, root)
|
|
245
|
+
else:
|
|
219
246
|
volume.cache_env_from(
|
|
220
247
|
self, self.env, root, platform=self.platform, build_s=self.env_build_s
|
|
221
248
|
)
|
|
222
|
-
except Exception as exc:
|
|
223
|
-
if not volume.store.backend.moves_on_runtime:
|
|
224
|
-
raise
|
|
225
|
-
self._not_using_drive(exc, after="not caching the environment")
|
|
226
249
|
self.keep_blobs_on_disk()
|
|
227
250
|
for volume in self.volumes:
|
|
228
251
|
self.attach(volume)
|
|
@@ -263,6 +286,7 @@ class Runtime:
|
|
|
263
286
|
if self._shut_down:
|
|
264
287
|
return
|
|
265
288
|
self._shut_down = True
|
|
289
|
+
self._close_pack()
|
|
266
290
|
timestamp = datetime.datetime.now(datetime.UTC).isoformat()
|
|
267
291
|
print(
|
|
268
292
|
f"{timestamp} letify: discarding {self.name} provider={self.provider.alias} "
|
|
@@ -371,9 +395,13 @@ class Runtime:
|
|
|
371
395
|
fn, args, kwargs, timeout=timeout, modules=self.env.ship_modules
|
|
372
396
|
)
|
|
373
397
|
args, kwargs = self._externalize(args, kwargs)
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
398
|
+
self._poll_pack()
|
|
399
|
+
try:
|
|
400
|
+
return self._call_with_data(
|
|
401
|
+
fn, args, kwargs, timeout, data_order, data_first_wave, replica
|
|
402
|
+
)
|
|
403
|
+
finally:
|
|
404
|
+
self._poll_pack()
|
|
377
405
|
|
|
378
406
|
def _call_with_data(
|
|
379
407
|
self,
|
|
@@ -695,46 +723,6 @@ class Runtime:
|
|
|
695
723
|
)
|
|
696
724
|
return digest, size
|
|
697
725
|
|
|
698
|
-
def pack_dir_to_store(self, path: str, backend: Any) -> tuple[str, int]:
|
|
699
|
-
"""Pack a directory inside the runtime and have the runtime upload the archive.
|
|
700
|
-
|
|
701
|
-
Spec "Materializing into a runtime": the archive goes from the runtime's blob cache
|
|
702
|
-
to the backend without crossing this process. This process creates the upload
|
|
703
|
-
session, because it holds the credential, and the runtime is sent that session's
|
|
704
|
-
URL alone. Returns ``(digest, size)``. Needs a persistent channel, and raises
|
|
705
|
-
rather than routing the archive through this process.
|
|
706
|
-
"""
|
|
707
|
-
if not self.persistent_channel:
|
|
708
|
-
raise RuntimeFailure("this channel keeps no worker that could upload the archive")
|
|
709
|
-
staging = f"{posixpath.dirname(path.rstrip('/'))}/.letify-pack"
|
|
710
|
-
value = self.request(
|
|
711
|
-
{"op": "pack_blob", "path": path, "dir": staging, "gzip": False}, timeout=3600
|
|
712
|
-
)
|
|
713
|
-
digest, size, directory = value["digest"], int(value["size"]), value["dir"]
|
|
714
|
-
try:
|
|
715
|
-
if not backend.has(digest):
|
|
716
|
-
target = backend.begin_upload(digest, size)
|
|
717
|
-
if target is None:
|
|
718
|
-
raise RuntimeFailure(f"the {backend.name} backend offered no upload")
|
|
719
|
-
sent = self.request(
|
|
720
|
-
{
|
|
721
|
-
"op": "push_blob",
|
|
722
|
-
"dir": directory,
|
|
723
|
-
"digest": digest,
|
|
724
|
-
"url": target["url"],
|
|
725
|
-
"headers": dict(target.get("headers") or {}), # type: ignore[call-overload]
|
|
726
|
-
"piece": target.get("piece"),
|
|
727
|
-
},
|
|
728
|
-
timeout=3600,
|
|
729
|
-
)
|
|
730
|
-
backend.record_upload(digest, str(sent["id"]))
|
|
731
|
-
finally:
|
|
732
|
-
self.exec(
|
|
733
|
-
f"import shutil\nshutil.rmtree({directory!r}, ignore_errors=True)\n",
|
|
734
|
-
timeout=120,
|
|
735
|
-
)
|
|
736
|
-
return digest, size
|
|
737
|
-
|
|
738
726
|
def put_path(
|
|
739
727
|
self,
|
|
740
728
|
local: Path,
|
|
@@ -937,24 +925,167 @@ class Runtime:
|
|
|
937
925
|
return False
|
|
938
926
|
|
|
939
927
|
def _worth_packing_to_drive(self, root: str) -> bool:
|
|
940
|
-
"""Whether the build took
|
|
928
|
+
"""Whether the build took at least twice as long as restoring from Drive would.
|
|
941
929
|
|
|
942
930
|
Spec "Materializing into a runtime": the archive goes from the runtime to Drive and
|
|
943
931
|
never over this link, so the gate is the estimated restore, the project directory's
|
|
944
|
-
size over the Drive download rate plus the same size over the extraction rate.
|
|
932
|
+
size over the Drive download rate plus the same size over the extraction rate. The
|
|
933
|
+
build must take ``DRIVE_PACK_FACTOR`` times that, because the pack has a cost of its
|
|
934
|
+
own that the comparison leaves out.
|
|
945
935
|
"""
|
|
946
936
|
size = self._tree_size(root)
|
|
947
937
|
if size is None or self.env_build_s is None:
|
|
948
938
|
return True
|
|
949
939
|
restore_s = size / DRIVE_DOWNLOAD_BPS + size / EXTRACT_BPS
|
|
950
|
-
if self.env_build_s
|
|
940
|
+
if self.env_build_s >= DRIVE_PACK_FACTOR * restore_s:
|
|
951
941
|
return True
|
|
952
942
|
self._say(
|
|
953
943
|
f"not caching the environment, because it built in {self.env_build_s:.0f} s "
|
|
954
|
-
f"and would take an estimated {restore_s:.0f} s to restore from Drive"
|
|
944
|
+
f"and would take an estimated {restore_s:.0f} s to restore from Drive, and a "
|
|
945
|
+
f"cache pays only when the build takes at least {DRIVE_PACK_FACTOR} times as long"
|
|
955
946
|
)
|
|
956
947
|
return False
|
|
957
948
|
|
|
949
|
+
# -- the pack that runs beside the calls ---------------------------------
|
|
950
|
+
|
|
951
|
+
def _start_drive_pack(self, volume: Volume, root: str) -> None:
|
|
952
|
+
"""Have the worker pack the environment and upload it on a thread of its own.
|
|
953
|
+
|
|
954
|
+
Spec "Materializing into a runtime": this returns at once, so the first call does
|
|
955
|
+
not wait for the pack. The ref and the build record are written when a poll sees
|
|
956
|
+
the upload finish. A failure to start leaves nothing cached and says so.
|
|
957
|
+
"""
|
|
958
|
+
staging = f"{posixpath.dirname(root.rstrip('/'))}/.letify-pack"
|
|
959
|
+
try:
|
|
960
|
+
if not self.persistent_channel:
|
|
961
|
+
raise RuntimeFailure("this channel keeps no worker that could upload the archive")
|
|
962
|
+
self.request(
|
|
963
|
+
{"op": "pack_push_start", "path": root, "dir": staging}, timeout=120
|
|
964
|
+
)
|
|
965
|
+
except Exception as exc:
|
|
966
|
+
self._not_using_drive(exc, after="not caching the environment")
|
|
967
|
+
return
|
|
968
|
+
self._pack = _DrivePack(volume)
|
|
969
|
+
threading.Thread(target=self._feed_pack, name="letify-pack", daemon=True).start()
|
|
970
|
+
|
|
971
|
+
def _feed_pack(self) -> None:
|
|
972
|
+
"""Hand the pack its upload session soon after the archive is written.
|
|
973
|
+
|
|
974
|
+
A call can run for hours, and the upload cannot start until this process has made
|
|
975
|
+
the session, so this does only that. Reporting stays with the calls and the close.
|
|
976
|
+
"""
|
|
977
|
+
while True:
|
|
978
|
+
time.sleep(PACK_POLL_S)
|
|
979
|
+
job = self._pack
|
|
980
|
+
if job is None or job.target_sent or self._shut_down:
|
|
981
|
+
return
|
|
982
|
+
self._poll_pack(report=False)
|
|
983
|
+
|
|
984
|
+
def _poll_pack(self, *, report: bool = True) -> bool:
|
|
985
|
+
"""Ask the worker how the pack is doing and act on the answer.
|
|
986
|
+
|
|
987
|
+
A packed archive is given its upload session. A finished upload is recorded, and
|
|
988
|
+
reported when ``report`` is set. False when the worker could not be asked.
|
|
989
|
+
"""
|
|
990
|
+
with self._pack_lock:
|
|
991
|
+
job = self._pack
|
|
992
|
+
if job is None or self.channel is None:
|
|
993
|
+
return True
|
|
994
|
+
try:
|
|
995
|
+
state = self.request({"op": "pack_push_state"}, timeout=30)
|
|
996
|
+
except Exception:
|
|
997
|
+
return False
|
|
998
|
+
kind = state.get("state")
|
|
999
|
+
backend = job.volume.store.backend
|
|
1000
|
+
if kind == "packed" and not job.target_sent:
|
|
1001
|
+
job.target_sent = True
|
|
1002
|
+
try:
|
|
1003
|
+
if backend.has(state["digest"]):
|
|
1004
|
+
# Another session stored this archive first, so there is nothing to send.
|
|
1005
|
+
self._send_target(None)
|
|
1006
|
+
self._pack = None
|
|
1007
|
+
self._record_pack(job, state["digest"], int(state["size"]))
|
|
1008
|
+
return True
|
|
1009
|
+
target = backend.begin_upload(state["digest"], int(state["size"]))
|
|
1010
|
+
if target is None:
|
|
1011
|
+
raise RuntimeFailure(f"the {backend.name} backend offered no upload")
|
|
1012
|
+
self._send_target(target)
|
|
1013
|
+
except Exception as exc:
|
|
1014
|
+
self._pack = None
|
|
1015
|
+
try:
|
|
1016
|
+
self._send_target(None)
|
|
1017
|
+
except Exception:
|
|
1018
|
+
pass
|
|
1019
|
+
self._not_using_drive(exc, after="not caching the environment")
|
|
1020
|
+
elif kind == "done" and report:
|
|
1021
|
+
self._pack = None
|
|
1022
|
+
backend.record_upload(state["digest"], str(state["id"]))
|
|
1023
|
+
self._record_pack(job, state["digest"], int(state["size"]))
|
|
1024
|
+
seconds = float(state["seconds"])
|
|
1025
|
+
size = int(state["size"])
|
|
1026
|
+
self._say(
|
|
1027
|
+
f"cached the environment, {size / 1_000_000_000:.1f} GB in {seconds:.0f} s "
|
|
1028
|
+
f"({size / 1024**2 / max(seconds, 0.001):.1f} MiB/s)"
|
|
1029
|
+
)
|
|
1030
|
+
elif kind == "failed" and report:
|
|
1031
|
+
self._pack = None
|
|
1032
|
+
self._not_using_drive(
|
|
1033
|
+
RuntimeFailure(str(state.get("reason"))), after="not caching the environment"
|
|
1034
|
+
)
|
|
1035
|
+
elif kind == "none":
|
|
1036
|
+
self._pack = None
|
|
1037
|
+
return True
|
|
1038
|
+
|
|
1039
|
+
def _send_target(self, target: dict[str, Any] | None) -> None:
|
|
1040
|
+
"""Give the worker the upload session, or None to abandon the pack."""
|
|
1041
|
+
payload: dict[str, Any] = {"op": "pack_push_target", "url": None}
|
|
1042
|
+
if target is not None:
|
|
1043
|
+
payload.update(
|
|
1044
|
+
url=target["url"],
|
|
1045
|
+
headers=dict(target.get("headers") or {}), # type: ignore[call-overload]
|
|
1046
|
+
piece=target.get("piece"),
|
|
1047
|
+
)
|
|
1048
|
+
self.request(payload, timeout=60)
|
|
1049
|
+
|
|
1050
|
+
def _record_pack(self, job: _DrivePack, digest: str, size: int) -> None:
|
|
1051
|
+
"""Write the ref and the build record, which only a finished upload earns."""
|
|
1052
|
+
job.volume.record_env(
|
|
1053
|
+
self.env, self.platform, digest, size, build_s=self.env_build_s
|
|
1054
|
+
)
|
|
1055
|
+
|
|
1056
|
+
def _close_pack(self) -> None:
|
|
1057
|
+
"""Wait for a running pack, up to ``PACK_CLOSE_WAIT_S``, then abandon it.
|
|
1058
|
+
|
|
1059
|
+
Spec "Materializing into a runtime": an abandoned pack records nothing, so the
|
|
1060
|
+
next session syncs and may pack again.
|
|
1061
|
+
"""
|
|
1062
|
+
job = self._pack
|
|
1063
|
+
if job is None:
|
|
1064
|
+
return
|
|
1065
|
+
deadline = time.monotonic() + PACK_CLOSE_WAIT_S
|
|
1066
|
+
try:
|
|
1067
|
+
while self._pack is not None:
|
|
1068
|
+
if not self._poll_pack():
|
|
1069
|
+
self._pack = None
|
|
1070
|
+
return
|
|
1071
|
+
if self._pack is None:
|
|
1072
|
+
return
|
|
1073
|
+
if time.monotonic() >= deadline:
|
|
1074
|
+
break
|
|
1075
|
+
time.sleep(0.2)
|
|
1076
|
+
with self._pack_lock:
|
|
1077
|
+
self._pack = None
|
|
1078
|
+
try:
|
|
1079
|
+
self._send_target(None)
|
|
1080
|
+
except Exception:
|
|
1081
|
+
pass
|
|
1082
|
+
self._say(
|
|
1083
|
+
"not caching the environment, because the session closed "
|
|
1084
|
+
f"{time.monotonic() - job.began:.0f} s into the pack"
|
|
1085
|
+
)
|
|
1086
|
+
except Exception:
|
|
1087
|
+
self._pack = None
|
|
1088
|
+
|
|
958
1089
|
def _restore_from(self, volume: Volume, where: dict[str, Any]) -> bool:
|
|
959
1090
|
"""Restore the environment from one volume's archive. True when the check passed.
|
|
960
1091
|
|
|
@@ -24,7 +24,7 @@ from pathlib import Path
|
|
|
24
24
|
from typing import TYPE_CHECKING, Any
|
|
25
25
|
|
|
26
26
|
from .. import protocol
|
|
27
|
-
from .cas import
|
|
27
|
+
from .cas import Store
|
|
28
28
|
|
|
29
29
|
if TYPE_CHECKING:
|
|
30
30
|
from ..declare.env import Env
|
|
@@ -236,22 +236,29 @@ class Volume:
|
|
|
236
236
|
"""
|
|
237
237
|
runtime = _session(target)
|
|
238
238
|
scratch = self.store.scratch_path() if self.store.streams else None
|
|
239
|
-
if
|
|
240
|
-
# The runtime uploads the archive itself, so it never crosses this process.
|
|
241
|
-
digest, size = runtime.pack_dir_to_store(path, self.store.backend)
|
|
242
|
-
info = BlobInfo(digest, size)
|
|
243
|
-
elif scratch is not None and runtime.persistent_channel:
|
|
239
|
+
if scratch is not None and runtime.persistent_channel:
|
|
244
240
|
# Streamed: the archive lands in a file beside the store and is renamed in.
|
|
245
241
|
digest, _size = runtime.pack_dir_to_file(path, scratch)
|
|
246
242
|
info = self.store.adopt_file(scratch, digest)
|
|
247
243
|
else:
|
|
248
244
|
payload, _digest = runtime.pack_dir(path)
|
|
249
245
|
info = self.store.put_bytes(payload)
|
|
250
|
-
self.
|
|
251
|
-
if build_s is not None:
|
|
252
|
-
self.store.point(self.build_ref(env, platform), f"{build_s:.1f} {info.size}")
|
|
246
|
+
self.record_env(env, platform, info.digest, info.size, build_s=build_s)
|
|
253
247
|
return info.digest
|
|
254
248
|
|
|
249
|
+
def record_env(
|
|
250
|
+
self, env: Env, platform: str | None, digest: str, size: int, *,
|
|
251
|
+
build_s: float | None = None,
|
|
252
|
+
) -> None:
|
|
253
|
+
"""Point the env ref at a stored archive and write the build record beside it.
|
|
254
|
+
|
|
255
|
+
A Drive archive is uploaded by the runtime in the background, so its ref is written
|
|
256
|
+
by the session that saw the upload finish, not by the pack that started it.
|
|
257
|
+
"""
|
|
258
|
+
self.store.point(self.env_ref(env, platform), digest)
|
|
259
|
+
if build_s is not None:
|
|
260
|
+
self.store.point(self.build_ref(env, platform), f"{build_s:.1f} {size}")
|
|
261
|
+
|
|
255
262
|
def build_ref(self, env: Env, platform: str | None = None) -> str:
|
|
256
263
|
"""The ref recording the build seconds and archive size beside the env ref."""
|
|
257
264
|
return f"{self.env_ref(env, platform)}.build"
|
|
@@ -15,6 +15,7 @@ import os
|
|
|
15
15
|
import re
|
|
16
16
|
import secrets
|
|
17
17
|
import select
|
|
18
|
+
import shlex
|
|
18
19
|
import socket
|
|
19
20
|
import struct
|
|
20
21
|
import subprocess
|
|
@@ -560,10 +561,23 @@ def begin(request: dict):
|
|
|
560
561
|
descriptor = os.open(key, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
561
562
|
with os.fdopen(descriptor, "w") as handle:
|
|
562
563
|
handle.write(request["private_key"])
|
|
564
|
+
# With a jump host the same identity and host-key policy must reach both hops, and
|
|
565
|
+
# -J does not carry command-line options to the jump connection, so the hop is an
|
|
566
|
+
# explicit ProxyCommand that repeats them.
|
|
567
|
+
jump = request.get("jump")
|
|
568
|
+
hop: list[str] = []
|
|
569
|
+
if jump:
|
|
570
|
+
proxy = (
|
|
571
|
+
"ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new"
|
|
572
|
+
f" -i {shlex.quote(str(key))}"
|
|
573
|
+
f" -W %h:%p -p {int(jump.get('port', 22))}"
|
|
574
|
+
f" {shlex.quote(str(jump['user']) + '@' + str(jump['address']))}"
|
|
575
|
+
)
|
|
576
|
+
hop = ["-o", f"ProxyCommand={proxy}"]
|
|
563
577
|
process = subprocess.Popen(
|
|
564
578
|
[
|
|
565
579
|
"ssh", "-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes",
|
|
566
|
-
"-o", "StrictHostKeyChecking=accept-new",
|
|
580
|
+
"-o", "StrictHostKeyChecking=accept-new", *hop,
|
|
567
581
|
"-R", f"0:127.0.0.1:{int(request.get('ssh_port', 22))}",
|
|
568
582
|
"-p", str(request["port"]), "-i", str(key),
|
|
569
583
|
f"{request['user']}@{request['address']}",
|