ray-vast 0.1.0__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.
Files changed (57) hide show
  1. ray_vast-0.1.0/.gitignore +8 -0
  2. ray_vast-0.1.0/CHANGELOG.md +50 -0
  3. ray_vast-0.1.0/LICENSE +21 -0
  4. ray_vast-0.1.0/PKG-INFO +108 -0
  5. ray_vast-0.1.0/README.md +81 -0
  6. ray_vast-0.1.0/pyproject.toml +50 -0
  7. ray_vast-0.1.0/scripts/build_shim.sh +18 -0
  8. ray_vast-0.1.0/scripts/shim_mutants.py +130 -0
  9. ray_vast-0.1.0/src/ray_vast/__init__.py +1 -0
  10. ray_vast-0.1.0/src/ray_vast/aws_sg.py +70 -0
  11. ray_vast-0.1.0/src/ray_vast/cli.py +32 -0
  12. ray_vast-0.1.0/src/ray_vast/commands.py +479 -0
  13. ray_vast-0.1.0/src/ray_vast/config.py +144 -0
  14. ray_vast-0.1.0/src/ray_vast/examples/cpu-heavy.yaml +27 -0
  15. ray_vast-0.1.0/src/ray_vast/examples/gpu-24gb.yaml +26 -0
  16. ray_vast-0.1.0/src/ray_vast/examples/gpu-80gb.yaml +27 -0
  17. ray_vast-0.1.0/src/ray_vast/head_server.py +281 -0
  18. ray_vast-0.1.0/src/ray_vast/pki.py +140 -0
  19. ray_vast-0.1.0/src/ray_vast/provider.py +403 -0
  20. ray_vast-0.1.0/src/ray_vast/shim/librayvast_shim.so +0 -0
  21. ray_vast-0.1.0/src/ray_vast/shim/rayvast_shim.c +267 -0
  22. ray_vast-0.1.0/src/ray_vast/state.py +149 -0
  23. ray_vast-0.1.0/src/ray_vast/vast_api.py +141 -0
  24. ray_vast-0.1.0/src/ray_vast/worker/bootloader.sh +32 -0
  25. ray_vast-0.1.0/src/ray_vast/worker/bootstrap.py +389 -0
  26. ray_vast-0.1.0/tests/conftest.py +70 -0
  27. ray_vast-0.1.0/tests/fakes/__init__.py +0 -0
  28. ray_vast-0.1.0/tests/fakes/fake_head.py +137 -0
  29. ray_vast-0.1.0/tests/fakes/fake_ray_pkg/ray/__init__.py +0 -0
  30. ray_vast-0.1.0/tests/fakes/fake_ray_pkg/ray/scripts/__init__.py +0 -0
  31. ray_vast-0.1.0/tests/fakes/fake_ray_pkg/ray/scripts/scripts.py +41 -0
  32. ray_vast-0.1.0/tests/fakes/fake_vast.py +48 -0
  33. ray_vast-0.1.0/tests/fakes/worker_world.py +104 -0
  34. ray_vast-0.1.0/tests/fixtures/vast_instance_poll.json +10 -0
  35. ray_vast-0.1.0/tests/fixtures/vast_offer.json +15 -0
  36. ray_vast-0.1.0/tests/integration/test_shim.py +223 -0
  37. ray_vast-0.1.0/tests/integration/test_worker_container.py +350 -0
  38. ray_vast-0.1.0/tests/live/test_up_down.py +92 -0
  39. ray_vast-0.1.0/tests/local_e2e/battery_subset.py +64 -0
  40. ray_vast-0.1.0/tests/local_e2e/e2e_harness.py +223 -0
  41. ray_vast-0.1.0/tests/local_e2e/fake_vast_server.py +192 -0
  42. ray_vast-0.1.0/tests/local_e2e/hold_nodes.py +25 -0
  43. ray_vast-0.1.0/tests/local_e2e/ping_busy.py +5 -0
  44. ray_vast-0.1.0/tests/local_e2e/scaled_provider.py +9 -0
  45. ray_vast-0.1.0/tests/local_e2e/test_local_e2e.py +186 -0
  46. ray_vast-0.1.0/tests/unit/conftest.py +109 -0
  47. ray_vast-0.1.0/tests/unit/test_aws_sg.py +183 -0
  48. ray_vast-0.1.0/tests/unit/test_bootstrap.py +592 -0
  49. ray_vast-0.1.0/tests/unit/test_cli.py +914 -0
  50. ray_vast-0.1.0/tests/unit/test_config.py +383 -0
  51. ray_vast-0.1.0/tests/unit/test_head_server.py +406 -0
  52. ray_vast-0.1.0/tests/unit/test_package.py +114 -0
  53. ray_vast-0.1.0/tests/unit/test_pki.py +213 -0
  54. ray_vast-0.1.0/tests/unit/test_provider.py +815 -0
  55. ray_vast-0.1.0/tests/unit/test_provider_ray_adapter.py +60 -0
  56. ray_vast-0.1.0/tests/unit/test_state.py +189 -0
  57. ray_vast-0.1.0/tests/unit/test_vast_api.py +293 -0
@@ -0,0 +1,8 @@
1
+ .DS_Store
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ site/
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-15)
4
+
5
+ Changes since 0.1.0rc1:
6
+
7
+ - `dashboard`: Ctrl-C closes the tunnel, prints `tunnel closed` and exits with code 0, with no traceback.
8
+ - The commands write each output line at once, also when stdout is a pipe or a file.
9
+ - The watchdog retries a heartbeat when a thread cannot start (`can't start new thread`). Only the heartbeat time limit ends the worker.
10
+ - The head log keeps the last line (the error) of a long destroy reason.
11
+ - A missing YAML file, bad YAML syntax or a bad YAML field value prints one line `ray-vast <command>: <message>` and exits with code 1, with no traceback.
12
+
13
+ ## 0.1.0rc1 (2026-10-14, TestPyPI)
14
+
15
+ First version. A release candidate on TestPyPI.
16
+
17
+ - Ray autoscaler v2 node provider (`VastNodeProvider`) for vast.ai workers. The head runs on AWS EC2 (`head_instance_type`, default `t3.large`).
18
+ - Commands: `ray-vast up`, `down`, `attach` and `dashboard`. Options: `--profile`, `--key-file`, `--key-name`, `--wheel`, `--index-url`.
19
+ - `up` makes an IAM role, 5 security groups, an instance profile and the head. It builds a derived image with ray-vast. By default, `pip` installs the CLI's own version (`ray-vast==<version>`) from PyPI. `--index-url URL` installs it from another index, for example TestPyPI. `--wheel FILE` installs a wheel file, and a source tree builds its own wheel (both for development). It stops if the cluster already exists. It measures the image size and sets `image_size_gb`.
20
+ - `down` ends the head first, then destroys the vast instances, then deletes the AWS resources. It finds everything by tag and label, so it works after a failed `up` and when the head is gone. It reports what is left (exit code 1 if anything). Without a vast key it cleans AWS and warns that vast instances were not swept.
21
+ - `dashboard` copies the token to `~/.ray-vast/<cluster>/token`, prints the `RAY_AUTH_MODE` and `RAY_AUTH_TOKEN_PATH` export line, and opens the SSH tunnel, so `ray job submit` works from the laptop.
22
+ - State files in `~/.ray-vast/`: `<cluster>.json`, `<cluster>.known_hosts`, `<cluster>/token`.
23
+ - Cluster names match `[A-Za-z0-9][A-Za-z0-9_-]{0,39}`.
24
+ - Cluster YAML with node types. Each type has declared resources (`CPU` minimum, `GPU` exact), `node_config.disk_gb` (default 50) and an `offer_query` in the vast API JSON form (MB units). ray-vast adds the filters `num_gpus`, `cpu_cores_effective`, `direct_port_count`, `verified`, `rentable` and `datacenter`. A stricter user filter on `cpu_cores_effective` or `direct_port_count` is allowed.
25
+ - Provider fields with defaults: `candidate_factor` 2, `cert_max_age_days` 365, `allow_non_datacenter` false, `order_by` `dph_total`, `head_instance_type` `t3.large`, `port_a` 7443, `port_b` 7444. Optional: `image_size_gb`, `key_name`, `key_file`.
26
+ - Example YAMLs: `gpu-24gb`, `gpu-80gb`, `cpu-heavy`.
27
+ - Images with an `ENTRYPOINT` that ends with `exec "$@"` work, for example `rayproject/ray:*-gpu`. `up` keeps the ENTRYPOINT and reads the image check from one marked line of its output.
28
+ - Candidates: the provider rents `candidate_factor` times as many instances as slots. The first candidates that enroll fill the slots. The provider destroys the others. The pull time limit comes from `image_size_gb` and `inet_down` (5 to 15 minutes).
29
+ - `max_dollars_per_hour` budget limit. It is required. The vast key is not a config field: it comes from `VAST_API_KEY` or `~/.config/vastai/vast_api_key`.
30
+ - Worker-first enrollment: the container command holds no secrets. The worker checks the host, proves its identity with a one-time join token, and then gets its certificate, key and the cluster token.
31
+ - Host checks: egress IP, no NET_RAW or NET_ADMIN (read from `CapBnd`), own network namespace, empty `/dev/shm`, and the 64 identity ports (checked from the head).
32
+ - TLS and token authentication on all Ray nodes. A new CA and token for each cluster. Node certificates are bound to the instance (IP SAN and URI SAN `rayvast:instance/<id>`); the head checks both on the mTLS port. The worker pins the CA by its SHA-256, rebuilds the CA PEM from the pinned DER, and refuses a PEM with more than one certificate.
33
+ - Two head server ports (`port_a`, `port_b`): TLS for download and enrollment, and mTLS for heartbeat and reports.
34
+ - Refusals before a valid join token (unknown instance, second enrollment, wrong source IP, wrong token) give 403 and do not destroy the instance. The head makes no vast call during enrollment.
35
+ - `LD_PRELOAD` networking shim: workers run behind NAT with no hairpin NAT and no overlay network.
36
+ - Watchdog: the bootstrap is PID 1. It sends a heartbeat every 30 seconds. It destroys its own instance after 5 minutes without a heartbeat, after 10 minutes without enrollment, on TERM, INT or HUP, and when Ray ends. It sends the reason to the head first.
37
+ - The bootloader (about 1.5 KB) never exits. It retries, and it destroys its own instance after a hash mismatch or after 10 minutes of failures.
38
+ - Wall-clock limit on every HTTP call of the worker. Vast calls do not follow redirects.
39
+ - Blocklist of bad hosts, in memory for each cluster.
40
+ - Security group rules for each worker IP, with reference counts for shared IPs, in up to 5 groups (about 299 worker IPs).
41
+ - `down` finds only the 5 group names `ray-vast-<cluster>-0` … `-4`. A cluster that an older development build made has other group names. Clean up such a cluster by hand.
42
+ - Start-up sweep: a new provider destroys every instance with the cluster label and removes the cluster's security group rules. The orphan sweep destroys instances that the provider does not know.
43
+ - Rent-loop breaker: after 3 failed slots in a row of one node type, the provider stops that type for 10 minutes.
44
+ - The head container runs `ray start --head --block` with no restart policy. If Ray's monitor dies, the head stops, and the workers destroy themselves within 5 minutes.
45
+ - Warnings: untested Ray version, `CPU` above the 58 worker ports, `allow_non_datacenter`, and certificate expiry in 14 days.
46
+ - Documented risks: the head holds the full vast account key; Ray tasks can read `CONTAINER_API_KEY` from `/proc/1/environ`. See `docs/security.md`.
47
+ - Tests: a fast default run (`pytest`, about 30 s) and a full run (`pytest -o addopts="" -m "not live and not local_e2e"`).
48
+ - Documents: the user docs site (`docs/`, MkDocs Material, published to https://duc-ph.github.io/ray-vast/), README, security model (`docs/security.md`), `CONTRIBUTING.md`.
49
+ - Tested with Ray 2.59.
50
+ - Published to TestPyPI only.
ray_vast-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Duc Pham
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.5
2
+ Name: ray-vast
3
+ Version: 0.1.0
4
+ Summary: A Ray autoscaler node provider for vast.ai workers with an AWS EC2 head.
5
+ Project-URL: Documentation, https://duc-ph.github.io/ray-vast/
6
+ Project-URL: Source, https://github.com/duc-ph/ray-vast
7
+ Project-URL: Changelog, https://duc-ph.github.io/ray-vast/reference/changelog/
8
+ Project-URL: Issues, https://github.com/duc-ph/ray-vast/issues
9
+ Author-email: Duc Pham <thiel.pham@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Requires-Python: >=3.10
13
+ Requires-Dist: boto3
14
+ Requires-Dist: cryptography>=42
15
+ Requires-Dist: pyyaml
16
+ Requires-Dist: ray[default]>=2.59
17
+ Requires-Dist: requests
18
+ Provides-Extra: test
19
+ Requires-Dist: build; extra == 'test'
20
+ Requires-Dist: freezegun; extra == 'test'
21
+ Requires-Dist: hatchling; extra == 'test'
22
+ Requires-Dist: moto[server]; extra == 'test'
23
+ Requires-Dist: pytest; extra == 'test'
24
+ Requires-Dist: pytest-timeout; extra == 'test'
25
+ Requires-Dist: responses; extra == 'test'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # ray-vast
29
+
30
+ (work in progress)
31
+
32
+ Run a Ray cluster with the head on AWS EC2 and GPU workers on vast.ai.
33
+
34
+ - The Ray head runs on one EC2 instance in your AWS account.
35
+ - The Ray autoscaler rents GPU workers from vast.ai when your jobs need them, and removes them when they are idle.
36
+ - The workers talk to the head over the public internet, across NAT, with TLS and a token.
37
+ - You do not need a VPN or an overlay network.
38
+
39
+ **Documentation: https://duc-ph.github.io/ray-vast/**
40
+
41
+ ## Why
42
+
43
+ - vast.ai has cheap GPUs. But a vast instance can stop at any time, and its IP can change.
44
+ - ray-vast keeps the head on EC2, so the cluster stays up. Only workers come and go.
45
+ - You keep the normal Ray workflow. Only the cluster commands change.
46
+ - A worker that loses the head destroys its own instance. You do not pay for lost workers.
47
+
48
+ ## Install
49
+
50
+ ```
51
+ pip install ray-vast "ray[default]==2.59.*"
52
+ ```
53
+
54
+ The Ray pin keeps Ray on your laptop at the same version as Ray in the cluster image. ray-vast is tested with Ray 2.59.
55
+
56
+ You need Python 3.10 or later, an AWS profile, an EC2 key pair, and a vast.ai API key.
57
+ Details: [Getting started](https://duc-ph.github.io/ray-vast/getting-started/).
58
+
59
+ ## Quick start
60
+
61
+ Copy an example cluster YAML (`gpu-24gb.yaml`, `gpu-80gb.yaml` or `cpu-heavy.yaml` in `ray_vast/examples/`).
62
+ Set the region, the budget (`max_dollars_per_hour`) and your EC2 key pair (`key_name`, `key_file`).
63
+
64
+ ```
65
+ ray-vast up cluster.yaml --profile my-aws-profile
66
+ ray-vast dashboard cluster.yaml --profile my-aws-profile # keep this terminal open
67
+ ```
68
+
69
+ In a second terminal, run the `export` line that `dashboard` printed, and submit a job:
70
+
71
+ ```
72
+ export RAY_AUTH_MODE=token RAY_AUTH_TOKEN_PATH=... # the line that dashboard printed
73
+ ray job submit --address http://127.0.0.1:8265 --working-dir . -- python train.py
74
+ ```
75
+
76
+ When you finish:
77
+
78
+ ```
79
+ ray-vast down cluster.yaml --profile my-aws-profile
80
+ ```
81
+
82
+ | Plain Ray | ray-vast |
83
+ |---|---|
84
+ | `ray up` | `ray-vast up` |
85
+ | `ray dashboard` | `ray-vast dashboard` |
86
+ | `ray down` | `ray-vast down` |
87
+
88
+ Some `ray` cluster commands do not work. Read [limits](https://duc-ph.github.io/ray-vast/reference/limits/#some-ray-commands-do-not-work). Use `--working-dir` and `runtime_env` to send code to the cluster.
89
+
90
+ ## Documentation
91
+
92
+ - [Getting started](https://duc-ph.github.io/ray-vast/getting-started/): your first cluster, step by step.
93
+ - How-to guides: [run your Ray workload](https://duc-ph.github.io/ray-vast/how-to/run-your-workload/), [choose node types](https://duc-ph.github.io/ray-vast/how-to/node-types/), [use GPU images](https://duc-ph.github.io/ray-vast/how-to/gpu-images/), [control cost](https://duc-ph.github.io/ray-vast/how-to/control-cost/), [troubleshoot](https://duc-ph.github.io/ray-vast/how-to/troubleshoot/), [clean up](https://duc-ph.github.io/ray-vast/how-to/clean-up/).
94
+ - Reference: [CLI](https://duc-ph.github.io/ray-vast/reference/cli/), [configuration](https://duc-ph.github.io/ray-vast/reference/configuration/), [Ray library support](https://duc-ph.github.io/ray-vast/reference/ray-libraries/), [limits](https://duc-ph.github.io/ray-vast/reference/limits/), [IAM permissions](https://duc-ph.github.io/ray-vast/reference/iam/), [changelog](https://duc-ph.github.io/ray-vast/reference/changelog/).
95
+ - Explanation: [architecture](https://duc-ph.github.io/ray-vast/explanation/architecture/), [enrollment](https://duc-ph.github.io/ray-vast/explanation/enrollment/), [worker life cycle](https://duc-ph.github.io/ray-vast/explanation/worker-lifecycle/), [design decisions](https://duc-ph.github.io/ray-vast/explanation/design-decisions/).
96
+ - [Security model](https://duc-ph.github.io/ray-vast/security/). Read it before you use the cluster with real data. A vast host has root access to the worker container.
97
+
98
+ ## Status
99
+
100
+ Version 0.1.0. Tested with Ray 2.59.
101
+
102
+ ## Contributing
103
+
104
+ Read [CONTRIBUTING.md](https://github.com/duc-ph/ray-vast/blob/main/ray-vast-pypi/CONTRIBUTING.md) for the tests and the docs build.
105
+
106
+ ## License
107
+
108
+ MIT. See [LICENSE](https://github.com/duc-ph/ray-vast/blob/main/ray-vast-pypi/LICENSE).
@@ -0,0 +1,81 @@
1
+ # ray-vast
2
+
3
+ (work in progress)
4
+
5
+ Run a Ray cluster with the head on AWS EC2 and GPU workers on vast.ai.
6
+
7
+ - The Ray head runs on one EC2 instance in your AWS account.
8
+ - The Ray autoscaler rents GPU workers from vast.ai when your jobs need them, and removes them when they are idle.
9
+ - The workers talk to the head over the public internet, across NAT, with TLS and a token.
10
+ - You do not need a VPN or an overlay network.
11
+
12
+ **Documentation: https://duc-ph.github.io/ray-vast/**
13
+
14
+ ## Why
15
+
16
+ - vast.ai has cheap GPUs. But a vast instance can stop at any time, and its IP can change.
17
+ - ray-vast keeps the head on EC2, so the cluster stays up. Only workers come and go.
18
+ - You keep the normal Ray workflow. Only the cluster commands change.
19
+ - A worker that loses the head destroys its own instance. You do not pay for lost workers.
20
+
21
+ ## Install
22
+
23
+ ```
24
+ pip install ray-vast "ray[default]==2.59.*"
25
+ ```
26
+
27
+ The Ray pin keeps Ray on your laptop at the same version as Ray in the cluster image. ray-vast is tested with Ray 2.59.
28
+
29
+ You need Python 3.10 or later, an AWS profile, an EC2 key pair, and a vast.ai API key.
30
+ Details: [Getting started](https://duc-ph.github.io/ray-vast/getting-started/).
31
+
32
+ ## Quick start
33
+
34
+ Copy an example cluster YAML (`gpu-24gb.yaml`, `gpu-80gb.yaml` or `cpu-heavy.yaml` in `ray_vast/examples/`).
35
+ Set the region, the budget (`max_dollars_per_hour`) and your EC2 key pair (`key_name`, `key_file`).
36
+
37
+ ```
38
+ ray-vast up cluster.yaml --profile my-aws-profile
39
+ ray-vast dashboard cluster.yaml --profile my-aws-profile # keep this terminal open
40
+ ```
41
+
42
+ In a second terminal, run the `export` line that `dashboard` printed, and submit a job:
43
+
44
+ ```
45
+ export RAY_AUTH_MODE=token RAY_AUTH_TOKEN_PATH=... # the line that dashboard printed
46
+ ray job submit --address http://127.0.0.1:8265 --working-dir . -- python train.py
47
+ ```
48
+
49
+ When you finish:
50
+
51
+ ```
52
+ ray-vast down cluster.yaml --profile my-aws-profile
53
+ ```
54
+
55
+ | Plain Ray | ray-vast |
56
+ |---|---|
57
+ | `ray up` | `ray-vast up` |
58
+ | `ray dashboard` | `ray-vast dashboard` |
59
+ | `ray down` | `ray-vast down` |
60
+
61
+ Some `ray` cluster commands do not work. Read [limits](https://duc-ph.github.io/ray-vast/reference/limits/#some-ray-commands-do-not-work). Use `--working-dir` and `runtime_env` to send code to the cluster.
62
+
63
+ ## Documentation
64
+
65
+ - [Getting started](https://duc-ph.github.io/ray-vast/getting-started/): your first cluster, step by step.
66
+ - How-to guides: [run your Ray workload](https://duc-ph.github.io/ray-vast/how-to/run-your-workload/), [choose node types](https://duc-ph.github.io/ray-vast/how-to/node-types/), [use GPU images](https://duc-ph.github.io/ray-vast/how-to/gpu-images/), [control cost](https://duc-ph.github.io/ray-vast/how-to/control-cost/), [troubleshoot](https://duc-ph.github.io/ray-vast/how-to/troubleshoot/), [clean up](https://duc-ph.github.io/ray-vast/how-to/clean-up/).
67
+ - Reference: [CLI](https://duc-ph.github.io/ray-vast/reference/cli/), [configuration](https://duc-ph.github.io/ray-vast/reference/configuration/), [Ray library support](https://duc-ph.github.io/ray-vast/reference/ray-libraries/), [limits](https://duc-ph.github.io/ray-vast/reference/limits/), [IAM permissions](https://duc-ph.github.io/ray-vast/reference/iam/), [changelog](https://duc-ph.github.io/ray-vast/reference/changelog/).
68
+ - Explanation: [architecture](https://duc-ph.github.io/ray-vast/explanation/architecture/), [enrollment](https://duc-ph.github.io/ray-vast/explanation/enrollment/), [worker life cycle](https://duc-ph.github.io/ray-vast/explanation/worker-lifecycle/), [design decisions](https://duc-ph.github.io/ray-vast/explanation/design-decisions/).
69
+ - [Security model](https://duc-ph.github.io/ray-vast/security/). Read it before you use the cluster with real data. A vast host has root access to the worker container.
70
+
71
+ ## Status
72
+
73
+ Version 0.1.0. Tested with Ray 2.59.
74
+
75
+ ## Contributing
76
+
77
+ Read [CONTRIBUTING.md](https://github.com/duc-ph/ray-vast/blob/main/ray-vast-pypi/CONTRIBUTING.md) for the tests and the docs build.
78
+
79
+ ## License
80
+
81
+ MIT. See [LICENSE](https://github.com/duc-ph/ray-vast/blob/main/ray-vast-pypi/LICENSE).
@@ -0,0 +1,50 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ray-vast"
7
+ dynamic = ["version"]
8
+ description = "A Ray autoscaler node provider for vast.ai workers with an AWS EC2 head."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.10"
13
+ authors = [{ name = "Duc Pham", email = "thiel.pham@gmail.com" }]
14
+ dependencies = ["ray[default]>=2.59", "boto3", "cryptography>=42", "pyyaml", "requests"]
15
+
16
+ [project.urls]
17
+ Documentation = "https://duc-ph.github.io/ray-vast/"
18
+ Source = "https://github.com/duc-ph/ray-vast"
19
+ Changelog = "https://duc-ph.github.io/ray-vast/reference/changelog/"
20
+ Issues = "https://github.com/duc-ph/ray-vast/issues"
21
+
22
+ [project.optional-dependencies]
23
+ test = ["pytest", "pytest-timeout", "moto[server]", "responses", "freezegun", "build", "hatchling"]
24
+
25
+ [project.scripts]
26
+ ray-vast = "ray_vast.cli:main"
27
+
28
+ [tool.hatch.version]
29
+ path = "src/ray_vast/__init__.py"
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["src/ray_vast"]
33
+
34
+ [tool.hatch.build.targets.sdist]
35
+ include = ["src", "tests", "scripts", "README.md", "LICENSE", "CHANGELOG.md", "pyproject.toml"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ markers = [
40
+ "live: uses real AWS or vast.ai (skipped by default)",
41
+ "slow: starts processes, containers, TLS servers or wheel builds, or waits on real timers (skipped by default)",
42
+ "local_e2e: runs a Ray head and workers in Docker on the dev box (step 11; skipped by default)",
43
+ ]
44
+ addopts = "-m 'not slow and not live and not local_e2e'"
45
+ # A hung test (for example a `down` wait loop) fails after this many seconds instead of stopping the run.
46
+ timeout = 300
47
+
48
+ [tool.ruff]
49
+ line-length = 120
50
+ src = ["src", "tests"]
@@ -0,0 +1,18 @@
1
+ #!/bin/sh
2
+ # Build the shim in manylinux2014 (glibc 2.17) and check it. Run where Docker works.
3
+ # The build goes to a temp dir; compare the printed sha256 with the committed .so.
4
+ set -eu
5
+ dir=$(cd "$(dirname "$0")/../src/ray_vast/shim" && pwd)
6
+ out=$(mktemp -d)
7
+ docker run --rm -v "$dir":/src:ro -v "$out":/out quay.io/pypa/manylinux2014_x86_64 sh -eu -c '
8
+ gcc -shared -fPIC -O2 -o /out/librayvast_shim.so /src/rayvast_shim.c -ldl
9
+ sha256sum /out/librayvast_shim.so
10
+ symbols=$(nm -D /out/librayvast_shim.so)
11
+ echo "$symbols"
12
+ echo "$symbols" | grep -q " T bind$" || { echo "missing T bind" >&2; exit 1; }
13
+ echo "$symbols" | grep -q " T connect$" || { echo "missing T connect" >&2; exit 1; }
14
+ newest=$(echo "$symbols" | grep -o "GLIBC_[0-9.]*" | sed "s/GLIBC_//" | sort -V | tail -1)
15
+ [ -z "$newest" ] || [ "$(printf "%s\n2.17\n" "$newest" | sort -V | tail -1)" = "2.17" ] || { echo "GLIBC_$newest is above 2.17" >&2; exit 1; }
16
+ echo "ok: symbols and glibc version"
17
+ '
18
+ echo "built: $out/librayvast_shim.so"
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env python3
2
+ """Mutation run for the shim tests (docs/implementation-plan.md step 10c).
3
+
4
+ Makes one mutant of src/ray_vast/shim/rayvast_shim.c for each entry in MUTANTS (one small change each), builds the
5
+ unchanged source and all mutants in quay.io/pypa/manylinux2014_x86_64 (as scripts/build_shim.sh does), runs
6
+ tests/integration/test_shim.py against each .so, and prints killed or survived. The unchanged build must pass.
7
+
8
+ Run from the package directory with the test venv, where Docker works:
9
+ .venv/bin/python scripts/shim_mutants.py
10
+ Exit code 0 when the unchanged build passes; the survivors are printed (explain each one in docs/test-results.md).
11
+ """
12
+ import hashlib
13
+ import os
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ PACKAGE = Path(__file__).resolve().parents[1]
20
+ SOURCE = PACKAGE / "src" / "ray_vast" / "shim" / "rayvast_shim.c"
21
+ TEST = PACKAGE / "tests" / "integration" / "test_shim.py"
22
+
23
+ # name: (text in the source, its replacement). Each text must occur exactly once.
24
+ MUTANTS = {
25
+ "rule1-removed": ("if (is_bind && is_localhost_port) {", "if (0) {"),
26
+ "rule1-target-private": (
27
+ "target.s_addr = htonl(INADDR_LOOPBACK);\n *loopback_bind = 1;",
28
+ "target = private_ip;\n *loopback_bind = 1;",
29
+ ),
30
+ "rule3-removed": ("} else if (is_public && !is_bind && is_localhost_port) {", "} else if (0) {"),
31
+ "rules2-4-removed": ("} else if (is_public && have_private) {", "} else if (0) {"),
32
+ "rule2-removed": ("} else if (is_public && have_private) {", "} else if (is_public && have_private && !is_bind) {"),
33
+ "rule4-removed": ("} else if (is_public && have_private) {", "} else if (is_public && have_private && is_bind) {"),
34
+ "port-compare-flipped": ("if (localhost_ports[i] == port)", "if (localhost_ports[i] != port)"),
35
+ "public-compare-flipped": (
36
+ "is_public = !v6_any && have_public && ip4->s_addr == public_ip.s_addr;",
37
+ "is_public = !v6_any && have_public && ip4->s_addr != public_ip.s_addr;",
38
+ ),
39
+ "same-target-compare-flipped": (
40
+ "if (!v6_any && ip4->s_addr == target.s_addr)", "if (!v6_any && ip4->s_addr != target.s_addr)"
41
+ ),
42
+ "port-range-flipped": ("p > 0 && p < 65536", "p > 0 && p > 65536"),
43
+ "port-list-first-only": ("s = strchr(end, ',');", "s = NULL;"),
44
+ "inet-length-check-flipped": (
45
+ "addr->sa_family == AF_INET && len >= sizeof(struct sockaddr_in))",
46
+ "addr->sa_family == AF_INET && len < sizeof(struct sockaddr_in))",
47
+ ),
48
+ "inet6-family-check-wrong": (
49
+ "addr->sa_family == AF_INET6 && len >= sizeof(struct sockaddr_in6)",
50
+ "addr->sa_family == AF_INET && len >= sizeof(struct sockaddr_in6)",
51
+ ),
52
+ "inet6-v4mapped-check-removed": (
53
+ "if (!IN6_IS_ADDR_V4MAPPED(&sin6->sin6_addr) && !v6_any)\n return 0;\n", ""
54
+ ),
55
+ "inet6-any-not-detected": ("v6_any = IN6_IS_ADDR_UNSPECIFIED(&sin6->sin6_addr);", "v6_any = 0;"),
56
+ "inet6-any-no-ffff-prefix": ("sin6->sin6_addr.s6_addr[10] = 0xff;\n", ""),
57
+ "inet6-length-wrong": ("*out_len = sizeof(struct sockaddr_in6);", "*out_len = sizeof(struct sockaddr_in);"),
58
+ "reuseport-removed": ("setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &one, sizeof one);", "(void)one;"),
59
+ "connect-uses-old-address": (
60
+ "return real_connect(fd, (const struct sockaddr *)&new_addr, new_len);", "return real_connect(fd, addr, len);"
61
+ ),
62
+ "errno-save-removed-in-log": (" errno = saved_errno;\n}\n\nint bind", "}\n\nint bind"),
63
+ "errno-save-removed-in-bind": (" errno = saved_errno;\n }\n if (changed)", " }\n if (changed)"),
64
+ "errno-save-removed-in-init": (" errno = saved_errno;\n}\n\n__attribute__", "}\n\n__attribute__"),
65
+ "env-copy-removed": (
66
+ "static char log_path[512];",
67
+ "static const char *log_path = \"\";",
68
+ ),
69
+ "log-bind-removed": ('log_rewrite(loopback_bind ? "bind+reuseport" : "bind", addr, &new_addr);', ""),
70
+ "log-connect-removed": ('log_rewrite("connect", addr, &new_addr);', ""),
71
+ }
72
+ # The env-copy mutant also needs the copy code replaced by a pointer assignment.
73
+ ENV_COPY_CODE = " log_path[0] = 0;\n if (s && strlen(s) < sizeof log_path)\n strcpy(log_path, s);\n"
74
+
75
+
76
+ def main():
77
+ source = SOURCE.read_text()
78
+ work = Path(tempfile.mkdtemp(prefix="shim-mutants-"))
79
+ # Each source keeps the name rayvast_shim.c (the name goes into the .so), so the unchanged build is the same file
80
+ # as the committed .so.
81
+ (work / "original").mkdir()
82
+ (work / "original" / "rayvast_shim.c").write_text(source)
83
+ for name, (old, new) in MUTANTS.items():
84
+ assert source.count(old) == 1, f"{name}: the text occurs {source.count(old)} times"
85
+ mutant = source.replace(old, new)
86
+ if name == "env-copy-removed":
87
+ assert mutant.count(ENV_COPY_CODE) == 1
88
+ mutant = mutant.replace(ENV_COPY_CODE, " log_path = s ? s : \"\";\n")
89
+ (work / name).mkdir()
90
+ (work / name / "rayvast_shim.c").write_text(mutant)
91
+
92
+ build = subprocess.run(
93
+ ["docker", "run", "--rm", "--name", f"st10c-shim-mutants-{os.getpid()}", "-u", f"{os.getuid()}:{os.getgid()}",
94
+ "-v", f"{work}:/work", "quay.io/pypa/manylinux2014_x86_64", "sh", "-eu", "-c",
95
+ 'for d in /work/*/; do cd "$d" && gcc -shared -fPIC -O2 -o librayvast_shim.so rayvast_shim.c -ldl; done'],
96
+ capture_output=True, text=True, check=False,
97
+ )
98
+ if build.returncode != 0:
99
+ sys.exit(f"build failed:\n{build.stdout}{build.stderr}")
100
+ print(f"mutants in {work}", flush=True)
101
+ committed = hashlib.sha256((SOURCE.parent / "librayvast_shim.so").read_bytes()).hexdigest()
102
+ rebuilt = hashlib.sha256((work / "original" / "librayvast_shim.so").read_bytes()).hexdigest()
103
+ if rebuilt != committed:
104
+ sys.exit(f"the unchanged source builds to sha256 {rebuilt}, not to the committed .so {committed}")
105
+ print("the unchanged source builds to the committed .so", flush=True)
106
+
107
+ def run_tests(so):
108
+ result = subprocess.run(
109
+ [sys.executable, "-m", "pytest", "-o", "addopts=", "-q", "-p", "no:cacheprovider", str(TEST)],
110
+ env={**os.environ, "RAYVAST_TEST_SHIM_SO": str(so)}, cwd=PACKAGE, capture_output=True, text=True, check=False,
111
+ )
112
+ if result.returncode not in (0, 1): # 1 = some tests failed; anything else is a broken run
113
+ sys.exit(f"pytest error for {so}:\n{result.stdout}{result.stderr}")
114
+ failed = [line.split(" ")[1].split("::")[-1] for line in result.stdout.splitlines() if line.startswith("FAILED")]
115
+ return result.returncode == 0, failed
116
+
117
+ passed, failed = run_tests(work / "original" / "librayvast_shim.so")
118
+ if not passed:
119
+ sys.exit(f"the unchanged source fails the tests: {failed}")
120
+ survivors = []
121
+ for name in MUTANTS:
122
+ survived, failed = run_tests(work / name / "librayvast_shim.so")
123
+ print(f"{'SURVIVED' if survived else 'killed '} {name}" + (f" ({', '.join(failed)})" if failed else ""), flush=True)
124
+ if survived:
125
+ survivors.append(name)
126
+ print(f"{len(MUTANTS) - len(survivors)} killed, {len(survivors)} survived: {', '.join(survivors) or '-'}")
127
+
128
+
129
+ if __name__ == "__main__":
130
+ main()
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,70 @@
1
+ """Ingress rules on the cluster's security groups (up to 5, design 8.3): TCP all ports from one worker IP, with the
2
+ description `ray-vast:<cluster>`. A rule goes into the first group with room."""
3
+ import botocore.config
4
+ import botocore.exceptions
5
+
6
+ from .vast_api import label
7
+
8
+ class SgQuotaError(Exception):
9
+ """No security group of the cluster has room for another worker IP."""
10
+
11
+
12
+ def ec2_client_config():
13
+ """botocore Config for the ec2 client that the caller passes to SecurityGroupRules."""
14
+ return botocore.config.Config(connect_timeout=5, read_timeout=30, retries={"max_attempts": 5, "mode": "standard"})
15
+
16
+
17
+ class SecurityGroupRules:
18
+ def __init__(self, ec2, group_ids, cluster):
19
+ self._ec2 = ec2
20
+ self._group_ids = list(group_ids)
21
+ self._description = label(cluster)
22
+
23
+ def _our_rules(self):
24
+ """Return {cidr: group id} for every rule of this cluster in its groups."""
25
+ groups = self._ec2.describe_security_groups(GroupIds=self._group_ids)["SecurityGroups"]
26
+ return {r["CidrIp"]: g["GroupId"] for g in groups for p in g["IpPermissions"] for r in p.get("IpRanges", [])
27
+ if r.get("Description") == self._description}
28
+
29
+ def allow_ip(self, ip):
30
+ """Add the rule to the first group with room. A rule that exists already (in any group) is success."""
31
+ cidr = f"{ip}/32"
32
+ if cidr in self._our_rules():
33
+ return
34
+ for group_id in self._group_ids:
35
+ try:
36
+ self._ec2.authorize_security_group_ingress(GroupId=group_id, IpPermissions=[{
37
+ "IpProtocol": "tcp", "FromPort": 0, "ToPort": 65535,
38
+ "IpRanges": [{"CidrIp": cidr, "Description": self._description}]}])
39
+ return
40
+ except botocore.exceptions.ClientError as e:
41
+ code = e.response["Error"]["Code"]
42
+ if code == "InvalidPermission.Duplicate": # success: the groups belong to this cluster only
43
+ return
44
+ if code != "RulesPerSecurityGroupLimitExceeded":
45
+ raise
46
+ raise SgQuotaError(f"all {len(self._group_ids)} security groups of the cluster are full; raise the quota in AWS "
47
+ "Service Quotas (VPC: inbound or outbound rules per security group)")
48
+
49
+ def _revoke(self, group_id, cidrs):
50
+ try:
51
+ self._ec2.revoke_security_group_ingress(GroupId=group_id, IpPermissions=[{
52
+ "IpProtocol": "tcp", "FromPort": 0, "ToPort": 65535, "IpRanges": [{"CidrIp": c} for c in cidrs]}])
53
+ except botocore.exceptions.ClientError as e:
54
+ if e.response["Error"]["Code"] != "InvalidPermission.NotFound":
55
+ raise
56
+
57
+ def revoke_ip(self, ip):
58
+ """Remove the rule from the group that holds it. A missing rule is success. Only a rule with our description."""
59
+ cidr = f"{ip}/32"
60
+ group_id = self._our_rules().get(cidr)
61
+ if group_id:
62
+ self._revoke(group_id, [cidr])
63
+
64
+ def revoke_all(self):
65
+ """Remove every rule of this cluster from all its groups; other rules (SSH, other clusters) stay."""
66
+ by_group = {}
67
+ for cidr, group_id in self._our_rules().items():
68
+ by_group.setdefault(group_id, []).append(cidr)
69
+ for group_id, cidrs in by_group.items():
70
+ self._revoke(group_id, cidrs)
@@ -0,0 +1,32 @@
1
+ import argparse
2
+ import os
3
+ import sys
4
+
5
+
6
+ def main(argv=None):
7
+ parser = argparse.ArgumentParser(prog="ray-vast", description="Ray clusters on vast.ai workers with an AWS EC2 head.")
8
+ subparsers = parser.add_subparsers(dest="command", required=True)
9
+ for name, help_text in [
10
+ ("up", "create the head and start the cluster"),
11
+ ("down", "destroy all workers and the head"),
12
+ ("attach", "open a shell on the head"),
13
+ ("dashboard", "open an SSH tunnel to the Ray dashboard"),
14
+ ]:
15
+ sub = subparsers.add_parser(name, help=help_text)
16
+ sub.add_argument("cluster_yaml")
17
+ sub.add_argument("--profile", default=os.environ.get("AWS_PROFILE"), help="AWS profile (default: AWS_PROFILE or the default credentials)")
18
+ if name in ("up", "attach", "dashboard"):
19
+ sub.add_argument("--key-file", help="SSH private key of the head (or provider.key_file)")
20
+ if name == "up":
21
+ sub.add_argument("--key-name", help="EC2 key pair name (or provider.key_name)")
22
+ sub.add_argument("--wheel", help="ray-vast wheel to install on the head, for development (default: in a source tree, build "
23
+ "the wheel; else install this CLI's own version from PyPI)")
24
+ sub.add_argument("--index-url", help="install ray-vast on the head from this package index, with PyPI as an extra index "
25
+ "(to test a release on TestPyPI; not for normal use)")
26
+ args = parser.parse_args(argv)
27
+ # Progress lines show at once also when stdout is a pipe or a file (there Python buffers whole blocks).
28
+ sys.stdout.reconfigure(line_buffering=True)
29
+
30
+ from .commands import run
31
+
32
+ return run(args)