title: Allow different dns zones for providers in e2e tests state: OPEN author: dboreham labels: comments: 0 assignees: projects: milestone: number: 51 -- The test CI job here: https://github.com/stirlingbridge/machine/blob/main/.github/workflows/e2e-test.yml currently uses the same DNS zone for both providers (Digitalocean and Vultr) via the E2E_DNS_ZONE variable. In reality it isn't possible to host the same DNS zone at two different DNS hosting providers, so we need to be able to specify different zones for each provider.
{
"file_path": "/home/david/projects/stirlingbridge/machine/.github/workflows/e2e-test.yml"
} 1→name: E2E Test
2→
3→on:
4→ push:
5→ branches: [main]
6→ pull_request:
7→ branches: [main]
8→ workflow_dispatch:
9→ schedule:
10→ # Run weekly on Monday at 06:00 UTC
11→ - cron: "0 6 * * 1"
12→
13→jobs:
14→ e2e:
15→ name: "E2E Tests (${{ matrix.provider }})"
16→ runs-on: ubuntu-latest
17→ environment: e2e
18→ strategy:
19→ fail-fast: false
20→ matrix:
21→ provider: [digital-ocean, vultr]
22→ steps:
23→ - name: "Clone project repository"
24→ uses: actions/checkout@v4
25→ - name: "Install uv"
26→ uses: astral-sh/setup-uv@v4
27→ - name: "Install Python"
28→ run: uv python install 3.13
29→ - name: "Install dependencies"
30→ run: uv sync
31→ - name: "Run E2E tests"
32→ env:
33→ E2E_PROVIDER: ${{ matrix.provider }}
34→ E2E_SSH_KEY: ${{ vars.E2E_SSH_KEY }}
35→ E2E_DNS_ZONE: ${{ vars.E2E_DNS_ZONE }}
36→ # DigitalOcean credentials
37→ E2E_DO_TOKEN: ${{ secrets.E2E_DO_TOKEN }}
38→ E2E_PROJECT: ${{ vars.E2E_PROJECT }}
39→ # Vultr credentials
40→ E2E_VULTR_API_KEY: ${{ secrets.E2E_VULTR_API_KEY }}
41→ run: uv run pytest tests/test_e2e.py -v -m e2e
42→tests/E2E.md:55:| `E2E_DNS_ZONE` | Yes | Provider-managed DNS zone for test records |
tests/E2E.md:84:export E2E_DNS_ZONE="test.example.com"
tests/E2E.md:98:export E2E_DNS_ZONE="example.com"
tests/E2E.md:113:| DigitalOcean | `E2E_DO_TOKEN` | `E2E_SSH_KEY`, `E2E_DNS_ZONE`, `E2E_PROJECT` |
tests/E2E.md:114:| Vultr | `E2E_VULTR_API_KEY` | `E2E_SSH_KEY`, `E2E_DNS_ZONE` |
.github/workflows/e2e-test.yml:35: E2E_DNS_ZONE: ${{ vars.E2E_DNS_ZONE }}
tests/test_e2e.py:13: E2E_DNS_ZONE - DNS zone managed by the provider (e.g. "test.example.com")
tests/test_e2e.py:58:E2E_DNS_ZONE = os.environ.get("E2E_DNS_ZONE")
tests/test_e2e.py:78:if not E2E_DNS_ZONE:
tests/test_e2e.py:79: _MISSING.append("E2E_DNS_ZONE")
tests/test_e2e.py:115: "dns-zone": E2E_DNS_ZONE,
tests/test_e2e.py:128: "dns-zone": E2E_DNS_ZONE,
tests/test_e2e.py:289: E2E_DNS_ZONE,
tests/test_e2e.py:296: assert len(a_records) >= 1, f"No A record found for {instance['name']}.{E2E_DNS_ZONE}"
tests/test_e2e.py:300: assert E2E_DNS_ZONE in instance["create_out"], f"DNS zone not mentioned in output:\n{instance['create_out']}" 1→"""End-to-end tests that create real VMs on a cloud provider.
2→
3→These tests require a real cloud provider environment and are NOT run as part of
4→the normal test suite. They must be invoked explicitly:
5→
6→ uv run pytest tests/test_e2e.py -v
7→
8→Provider selection:
9→ E2E_PROVIDER - Provider name: "digital-ocean" (default) or "vultr"
10→
11→Required environment variables (all providers):
12→ E2E_SSH_KEY - Name of an SSH key already registered with the provider
13→ E2E_DNS_ZONE - DNS zone managed by the provider (e.g. "test.example.com")
14→
15→Required environment variables (DigitalOcean):
16→ E2E_DO_TOKEN - DigitalOcean API token
17→ E2E_PROJECT - DO project name to assign droplets to
18→
19→Required environment variables (Vultr):
20→ E2E_VULTR_API_KEY - Vultr API key
21→
22→Optional environment variables:
23→ E2E_REGION - Region slug (default: provider-specific)
24→ E2E_IMAGE - Image slug or ID (default: provider-specific)
25→ E2E_SIZE - Machine size slug (default: provider-specific)
26→"""
27→
28→import json
29→import os
30→import subprocess
31→import uuid
32→
33→import pytest
34→
35→
36→# ---------------------------------------------------------------------------
37→# Provider configuration
38→# ---------------------------------------------------------------------------
39→
40→E2E_PROVIDER = os.environ.get("E2E_PROVIDER", "digital-ocean")
41→
42→_PROVIDER_DEFAULTS = {
43→ "digital-ocean": {
44→ "region": "nyc1",
45→ "image": "ubuntu-24-04-x64",
46→ "size": "s-1vcpu-512mb-10gb",
47→ },
48→ "vultr": {
49→ "region": "ewr",
50→ "image": "2136",
51→ "size": "vc2-1c-1gb",
52→ },
53→}
54→
55→_defaults = _PROVIDER_DEFAULTS.get(E2E_PROVIDER, _PROVIDER_DEFAULTS["digital-ocean"])
56→
57→E2E_SSH_KEY = os.environ.get("E2E_SSH_KEY")
58→E2E_DNS_ZONE = os.environ.get("E2E_DNS_ZONE")
59→E2E_REGION = os.environ.get("E2E_REGION", _defaults["region"])
60→E2E_IMAGE = os.environ.get("E2E_IMAGE", _defaults["image"])
61→E2E_SIZE = os.environ.get("E2E_SIZE", _defaults["size"])
62→
63→# Provider-specific credentials
64→E2E_DO_TOKEN = os.environ.get("E2E_DO_TOKEN")
65→E2E_PROJECT = os.environ.get("E2E_PROJECT")
66→E2E_VULTR_API_KEY = os.environ.get("E2E_VULTR_API_KEY")
67→
68→
69→# ---------------------------------------------------------------------------
70→# Skip the entire module if credentials are not provided
71→# ---------------------------------------------------------------------------
72→
73→pytestmark = pytest.mark.e2e
74→
75→_MISSING = []
76→if not E2E_SSH_KEY:
77→ _MISSING.append("E2E_SSH_KEY")
78→if not E2E_DNS_ZONE:
79→ _MISSING.append("E2E_DNS_ZONE")
80→
81→if E2E_PROVIDER == "digital-ocean":
82→ if not E2E_DO_TOKEN:
83→ _MISSING.append("E2E_DO_TOKEN")
84→ if not E2E_PROJECT:
85→ _MISSING.append("E2E_PROJECT")
86→elif E2E_PROVIDER == "vultr":
87→ if not E2E_VULTR_API_KEY:
88→ _MISSING.append("E2E_VULTR_API_KEY")
89→else:
90→ _MISSING.append(f"E2E_PROVIDER (unknown provider: {E2E_PROVIDER})")
91→
92→if _MISSING:
93→ pytestmark = [
94→ pytest.mark.e2e,
95→ pytest.mark.skip(reason=f"E2E env vars not set: {', '.join(_MISSING)}"),
96→ ]
97→
98→
99→# ---------------------------------------------------------------------------
100→# Helpers
101→# ---------------------------------------------------------------------------
102→
103→
104→def _unique_name(prefix="e2etest"):
105→ """Generate a short unique instance name safe for DNS."""
106→ return f"{prefix}-{uuid.uuid4().hex[:8]}"
107→
108→
109→def _write_config(path, **overrides):
110→ """Write a minimal config file for the e2e test run."""
111→ if E2E_PROVIDER == "digital-ocean":
112→ cfg = {
113→ "access-token": E2E_DO_TOKEN,
114→ "ssh-key": E2E_SSH_KEY,
115→ "dns-zone": E2E_DNS_ZONE,
116→ "machine-size": E2E_SIZE,
117→ "image": E2E_IMAGE,
118→ "region": E2E_REGION,
119→ "project": E2E_PROJECT,
120→ }
121→ cfg.update(overrides)
122→ provider_lines = "\n".join(f" {k}: {v}" for k, v in cfg.items())
123→ content = f"digital-ocean:\n{provider_lines}\nmachines:\n e2e-basic:\n new-user-name: e2euser\n"
124→ elif E2E_PROVIDER == "vultr":
125→ cfg = {
126→ "api-key": E2E_VULTR_API_KEY,
127→ "ssh-key": E2E_SSH_KEY,
128→ "dns-zone": E2E_DNS_ZONE,
129→ "machine-size": E2E_SIZE,
130→ "image": E2E_IMAGE,
131→ "region": E2E_REGION,
132→ }
133→ cfg.update(overrides)
134→ provider_lines = "\n".join(f" {k}: {v}" for k, v in cfg.items())
135→ content = f"vultr:\n{provider_lines}\nmachines:\n e2e-basic:\n new-user-name: e2euser\n"
136→
137→ with open(path, "w") as f:
138→ f.write(content)
139→
140→
141→def run_machine(*args, config_file=None, session_id=None):
142→ """Run the machine CLI as a subprocess with the given arguments."""
143→ cmd = ["uv", "run", "machine"]
144→ if config_file:
145→ cmd += ["--config-file", str(config_file)]
146→ if session_id:
147→ cmd += ["--session-id", session_id]
148→ cmd += list(args)
149→ result = subprocess.run(cmd, capture_output=True, text=True, timeout=300)
150→ return result
151→
152→
153→def _extract_instance_id(output_text):
154→ """Extract the instance ID from CLI output like 'New droplet created with id: 12345'.
155→
156→ Handles both numeric IDs (DigitalOcean) and UUID IDs (Vultr).
157→ """
158→ for line in output_text.splitlines():
159→ if "id:" in line.lower():
160→ parts = line.split("id:")
161→ if len(parts) >= 2:
162→ candidate = parts[-1].strip()
163→ if candidate:
164→ return candidate
165→ return None
166→
167→
168→# ---------------------------------------------------------------------------
169→# Fixtures
170→# ---------------------------------------------------------------------------
171→
172→
173→@pytest.fixture(scope="module")
174→def config_file(tmp_path_factory):
175→ """Write a config file that lives for the whole test module."""
176→ path = tmp_path_factory.mktemp("e2e") / "config.yml"
177→ _write_config(path)
178→ return path
179→
180→
181→@pytest.fixture(scope="module")
182→def session_id():
183→ """A unique session id shared across all tests in this module."""
184→ return uuid.uuid4().hex[:8]
185→
186→
187→@pytest.fixture(scope="class")
188→def instance(config_file, session_id):
189→ """Create a single instance with all features and destroy it after all tests.
190→
191→ The instance is created with DNS, a machine type (cloud-init), a custom tag,
192→ and --wait-for-ip so that all aspects can be verified by individual tests.
193→ """
194→ name = _unique_name()
195→ custom_tag = f"e2e-tag-{uuid.uuid4().hex[:6]}"
196→
197→ # ---- CREATE with all features ------------------------------------------
198→ result = run_machine(
199→ "create",
200→ "--name",
201→ name,
202→ "--type",
203→ "e2e-basic",
204→ "--update-dns",
205→ "--tag",
206→ custom_tag,
207→ "--wait-for-ip",
208→ config_file=config_file,
209→ session_id=session_id,
210→ )
211→ assert result.returncode == 0, f"create failed: {result.stderr}"
212→ create_out = result.stdout + result.stderr
213→ instance_id = _extract_instance_id(create_out)
214→ assert instance_id, f"Could not find instance id in output:\n{create_out}"
215→
216→ info = {
217→ "name": name,
218→ "id": instance_id,
219→ "custom_tag": custom_tag,
220→ "create_out": create_out,
221→ }
222→
223→ yield info
224→
225→ # ---- TEARDOWN: destroy with DNS cleanup --------------------------------
226→ run_machine(
227→ "--verbose",
228→ "destroy",
229→ "--no-confirm",
230→ "--delete-dns",
231→ instance_id,
232→ config_file=config_file,
233→ session_id=session_id,
234→ )
235→
236→
237→# ---------------------------------------------------------------------------
238→# Tests — one instance, many assertions
239→# ---------------------------------------------------------------------------
240→
241→
242→class TestInstanceLifecycle:
243→ """Create one instance with all features and verify each aspect independently.
244→
245→ A single instance is created (via the class-scoped ``instance`` fixture) with
246→ DNS, a machine type, and a custom tag. Each test method verifies a different
247→ aspect so that failures are reported individually. The instance is destroyed
248→ automatically after all tests complete.
249→ """
250→
251→ def test_instance_appears_in_list(self, instance, config_file, session_id):
252→ """Verify the instance shows up in ``list`` with the correct name."""
253→ result = run_machine(
254→ "list",
255→ "--output",
256→ "json",
257→ config_file=config_file,
258→ session_id=session_id,
259→ )
260→ assert result.returncode == 0, f"list failed: {result.stderr}"
261→ instances = json.loads(result.stdout)
262→ matched = [i for i in instances if str(i["id"]) == instance["id"]]
263→ assert len(matched) == 1, f"Expected 1 instance with id {instance['id']}, got {len(matched)}"
264→ assert matched[0]["name"] == instance["name"]
265→
266→ def test_instance_has_ip(self, instance, config_file, session_id):
267→ """Verify the instance was assigned an IP address."""
268→ result = run_machine(
269→ "list",
270→ "--output",
271→ "json",
272→ config_file=config_file,
273→ session_id=session_id,
274→ )
275→ assert result.returncode == 0
276→ instances = json.loads(result.stdout)
277→ matched = [i for i in instances if str(i["id"]) == instance["id"]]
278→ assert len(matched) == 1
279→ assert matched[0]["ip"] is not None, "Instance has no IP address"
280→
281→ def test_dns_record_created(self, instance, config_file, session_id):
282→ """Verify that a DNS A record was created for the instance."""
283→ result = run_machine(
284→ "list-domain",
285→ "--name",
286→ instance["name"],
287→ "--output",
288→ "json",
289→ E2E_DNS_ZONE,
290→ config_file=config_file,
291→ session_id=session_id,
292→ )
293→ assert result.returncode == 0, f"list-domain failed: {result.stderr}"
294→ records = json.loads(result.stdout)
295→ a_records = [r for r in records if r.get("name") == instance["name"] and r.get("type") == "A"]
296→ assert len(a_records) >= 1, f"No A record found for {instance['name']}.{E2E_DNS_ZONE}"
297→
298→ def test_dns_zone_in_create_output(self, instance):
299→ """Verify that DNS zone was mentioned in the create output."""
300→ assert E2E_DNS_ZONE in instance["create_out"], f"DNS zone not mentioned in output:\n{instance['create_out']}"
301→
302→ def test_type_tag_applied(self, instance, config_file, session_id):
303→ """Verify that the machine type tag was applied and is filterable."""
304→ result = run_machine(
305→ "list",
306→ "--type",
307→ "e2e-basic",
308→ "--output",
309→ "json",
310→ config_file=config_file,
311→ session_id=session_id,
312→ )
313→ assert result.returncode == 0
314→ instances = json.loads(result.stdout)
315→ matched = [i for i in instances if str(i["id"]) == instance["id"]]
316→ assert len(matched) == 1, "Instance not found when filtering by type e2e-basic"
317→ assert matched[0]["type"] == "e2e-basic", "Type tag mismatch"
318→
319→ def test_custom_tag_applied(self, instance, config_file, session_id):
320→ """Verify that the custom tag was applied and is filterable."""
321→ result = run_machine(
322→ "list",
323→ "--tag",
324→ instance["custom_tag"],
325→ "--output",
326→ "json",
327→ config_file=config_file,
328→ session_id=session_id,
329→ )
330→ assert result.returncode == 0
331→ instances = json.loads(result.stdout)
332→ matched = [i for i in instances if str(i["id"]) == instance["id"]]
333→ assert len(matched) == 1, f"Instance not found with tag {instance['custom_tag']}"
334→The issue is straightforward: the workflow uses a single E2E_DNS_ZONE for both providers, but each provider needs its own DNS zone. Here's the plan:
E2E_DO_DNS_ZONE, E2E_VULTR_DNS_ZONE) in the workflowE2E_DNS_ZONELet me implement this.
E2E_SSH_KEY: ${{ vars.E2E_SSH_KEY }}
E2E_DNS_ZONE: ${{ vars.E2E_DNS_ZONE }}
# DigitalOcean credentials
E2E_DO_TOKEN: ${{ secrets.E2E_DO_TOKEN }}
E2E_PROJECT: ${{ vars.E2E_PROJECT }}
# Vultr credentials
E2E_VULTR_API_KEY: ${{ secrets.E2E_VULTR_API_KEY }} E2E_SSH_KEY: ${{ vars.E2E_SSH_KEY }}
# DigitalOcean credentials
E2E_DO_TOKEN: ${{ secrets.E2E_DO_TOKEN }}
E2E_DO_DNS_ZONE: ${{ vars.E2E_DO_DNS_ZONE }}
E2E_PROJECT: ${{ vars.E2E_PROJECT }}
# Vultr credentials
E2E_VULTR_API_KEY: ${{ secrets.E2E_VULTR_API_KEY }}
E2E_VULTR_DNS_ZONE: ${{ vars.E2E_VULTR_DNS_ZONE }}The file /home/david/projects/stirlingbridge/machine/.github/workflows/e2e-test.yml has been updated successfully.
E2E_DNS_ZONE - DNS zone managed by the provider (e.g. "test.example.com")
Required environment variables (DigitalOcean):
E2E_DO_TOKEN - DigitalOcean API token
E2E_PROJECT - DO project name to assign droplets to
Required environment variables (Vultr):
E2E_VULTR_API_KEY - Vultr API keyRequired environment variables (DigitalOcean):
E2E_DO_TOKEN - DigitalOcean API token
E2E_DO_DNS_ZONE - DNS zone hosted at DigitalOcean (e.g. "do.example.com")
E2E_PROJECT - DO project name to assign droplets to
Required environment variables (Vultr):
E2E_VULTR_API_KEY - Vultr API key
E2E_VULTR_DNS_ZONE - DNS zone hosted at Vultr (e.g. "vultr.example.com")The file /home/david/projects/stirlingbridge/machine/tests/test_e2e.py has been updated successfully.
E2E_SSH_KEY = os.environ.get("E2E_SSH_KEY")
E2E_DNS_ZONE = os.environ.get("E2E_DNS_ZONE")E2E_SSH_KEY = os.environ.get("E2E_SSH_KEY")
# Per-provider DNS zones
E2E_DO_DNS_ZONE = os.environ.get("E2E_DO_DNS_ZONE")
E2E_VULTR_DNS_ZONE = os.environ.get("E2E_VULTR_DNS_ZONE")
# Select the DNS zone for the active provider
if E2E_PROVIDER == "digital-ocean":
E2E_DNS_ZONE = E2E_DO_DNS_ZONE
elif E2E_PROVIDER == "vultr":
E2E_DNS_ZONE = E2E_VULTR_DNS_ZONE
else:
E2E_DNS_ZONE = NoneThe file /home/david/projects/stirlingbridge/machine/tests/test_e2e.py has been updated successfully.
if not E2E_DNS_ZONE:
_MISSING.append("E2E_DNS_ZONE")
if E2E_PROVIDER == "digital-ocean":
if not E2E_DO_TOKEN:
_MISSING.append("E2E_DO_TOKEN")
if not E2E_PROJECT:
_MISSING.append("E2E_PROJECT")
elif E2E_PROVIDER == "vultr":
if not E2E_VULTR_API_KEY:
_MISSING.append("E2E_VULTR_API_KEY")if E2E_PROVIDER == "digital-ocean":
if not E2E_DO_TOKEN:
_MISSING.append("E2E_DO_TOKEN")
if not E2E_DO_DNS_ZONE:
_MISSING.append("E2E_DO_DNS_ZONE")
if not E2E_PROJECT:
_MISSING.append("E2E_PROJECT")
elif E2E_PROVIDER == "vultr":
if not E2E_VULTR_API_KEY:
_MISSING.append("E2E_VULTR_API_KEY")
if not E2E_VULTR_DNS_ZONE:
_MISSING.append("E2E_VULTR_DNS_ZONE")The file /home/david/projects/stirlingbridge/machine/tests/test_e2e.py has been updated successfully.
1→# End-to-End Tests
2→
3→These tests verify that `machine` can create and manage real VMs on a cloud provider, including DNS record lifecycle. They are **not** run as part of the normal test suite due to cost and runtime.
4→
5→Tests can be run against **DigitalOcean** or **Vultr** by setting the `E2E_PROVIDER` environment variable.
6→
7→## Prerequisites
8→
9→### DigitalOcean
10→
11→You need a DigitalOcean account with:
12→
13→- An API token with the required permissions (see below)
14→- An SSH key registered in the account
15→- A DNS zone managed by DigitalOcean (e.g. `example.com`). Note that Vultr does not have full DNS hosting functionality: it only supports hosting second-level domains, not subdomains (irrespective of what their documentation states). So `example.com` works but `test.example.com` does not.
16→- A project to assign droplets to
17→
18→#### API Token Permissions
19→
20→The DigitalOcean API token must be a **custom token** with these scopes enabled:
21→
22→| Scope | Access | Used for |
23→|---|---|---|
24→| `droplet` | read, create, delete | Creating, listing, and destroying test droplets |
25→| `ssh_key` | read | Looking up SSH keys by name |
26→| `domain` | read, create, delete | Creating and removing DNS A records |
27→| `project` | read, update | Listing projects and assigning droplets to them |
28→| `tag` | read, create | Tagging droplets by type and custom tags |
29→
30→A full-access read/write token will also work, but a scoped token is recommended.
31→
32→### Vultr
33→
34→You need a Vultr account with:
35→
36→- An API key (from https://my.vultr.com/settings/#settingsapi)
37→- An SSH key registered in the account
38→- A DNS zone managed by Vultr (e.g. `example.com`)
39→
40→The Vultr API key has full access by default; there is no scope configuration.
41→
42→## Environment Variables
43→
44→### Provider Selection
45→
46→| Variable | Required | Description |
47→|---|---|---|
48→| `E2E_PROVIDER` | No | Provider to test: `digital-ocean` (default) or `vultr` |
49→
50→### Common (all providers)
51→
52→| Variable | Required | Description |
53→|---|---|---|
54→| `E2E_SSH_KEY` | Yes | Name of an SSH key registered with the provider |
55→| `E2E_DNS_ZONE` | Yes | Provider-managed DNS zone for test records |
56→| `E2E_REGION` | No | Region slug (default: provider-specific, see below) |
57→| `E2E_IMAGE` | No | Image slug or ID (default: provider-specific, see below) |
58→| `E2E_SIZE` | No | Instance size (default: provider-specific, see below) |
59→
60→### DigitalOcean
61→
62→| Variable | Required | Description |
63→|---|---|---|
64→| `E2E_DO_TOKEN` | Yes | DigitalOcean API token |
65→| `E2E_PROJECT` | Yes | DO project to assign droplets to |
66→
67→Defaults: region `nyc1`, image `ubuntu-24-04-x64`, size `s-1vcpu-512mb-10gb`
68→
69→### Vultr
70→
71→| Variable | Required | Description |
72→|---|---|---|
73→| `E2E_VULTR_API_KEY` | Yes | Vultr API key |
74→
75→Defaults: region `ewr`, image `2136` (Ubuntu 24.04), size `vc2-1c-1gb`
76→
77→## Running
78→
79→### DigitalOcean
80→
81→```bash
82→export E2E_DO_TOKEN="dop_v1_..."
83→export E2E_SSH_KEY="my-ssh-key"
84→export E2E_DNS_ZONE="test.example.com"
85→export E2E_PROJECT="my-project"
86→
87→make test-e2e
88→# Or directly
89→uv run pytest tests/test_e2e.py -v -m e2e
90→```
91→
92→### Vultr
93→
94→```bash
95→export E2E_PROVIDER="vultr"
96→export E2E_VULTR_API_KEY="..."
97→export E2E_SSH_KEY="my-ssh-key"
98→export E2E_DNS_ZONE="example.com"
99→
100→make test-e2e
101→# Or directly
102→uv run pytest tests/test_e2e.py -v -m e2e
103→```
104→
105→The normal `make test` (and CI) will **skip** these tests automatically.
106→
107→## CI
108→
109→The GitHub Actions workflow (`.github/workflows/e2e-test.yml`) runs e2e tests for both providers in parallel using a matrix strategy. Each provider run requires its own credentials configured in the `e2e` GitHub environment:
110→
111→| Provider | Secrets | Variables |
112→|---|---|---|
113→| DigitalOcean | `E2E_DO_TOKEN` | `E2E_SSH_KEY`, `E2E_DNS_ZONE`, `E2E_PROJECT` |
114→| Vultr | `E2E_VULTR_API_KEY` | `E2E_SSH_KEY`, `E2E_DNS_ZONE` |
115→
116→If credentials for a provider are not configured, that provider's test run will be skipped automatically.
117→
118→## What's Tested
119→
120→- **Instance lifecycle** — create, list, destroy
121→- **DNS lifecycle** — create with `--update-dns`, verify A record, destroy with `--delete-dns`
122→- **Cloud-init initialization** — create with `--type`, verify type tag
123→- **Custom tags** — create with `--tag`, verify tag filtering
124→
125→## Cleanup
126→
127→Each test cleans up after itself. All test instances use unique names prefixed with `e2etest-` so they are easy to identify.
128→
129→## Cost
130→
131→Tests use the smallest available instance size and destroy VMs immediately after verification, so cost is minimal.
132→### Common (all providers) | Variable | Required | Description | |---|---|---| | `E2E_SSH_KEY` | Yes | Name of an SSH key registered with the provider | | `E2E_DNS_ZONE` | Yes | Provider-managed DNS zone for test records | | `E2E_REGION` | No | Region slug (default: provider-specific, see below) | | `E2E_IMAGE` | No | Image slug or ID (default: provider-specific, see below) | | `E2E_SIZE` | No | Instance size (default: provider-specific, see below) | ### DigitalOcean | Variable | Required | Description | |---|---|---| | `E2E_DO_TOKEN` | Yes | DigitalOcean API token | | `E2E_PROJECT` | Yes | DO project to assign droplets to | Defaults: region `nyc1`, image `ubuntu-24-04-x64`, size `s-1vcpu-512mb-10gb` ### Vultr | Variable | Required | Description | |---|---|---| | `E2E_VULTR_API_KEY` | Yes | Vultr API key | Defaults: region `ewr`, image `2136` (Ubuntu 24.04), size `vc2-1c-1gb`
### Common (all providers) | Variable | Required | Description | |---|---|---| | `E2E_SSH_KEY` | Yes | Name of an SSH key registered with the provider | | `E2E_REGION` | No | Region slug (default: provider-specific, see below) | | `E2E_IMAGE` | No | Image slug or ID (default: provider-specific, see below) | | `E2E_SIZE` | No | Instance size (default: provider-specific, see below) | ### DigitalOcean | Variable | Required | Description | |---|---|---| | `E2E_DO_TOKEN` | Yes | DigitalOcean API token | | `E2E_DO_DNS_ZONE` | Yes | DNS zone hosted at DigitalOcean (e.g. `do.example.com`) | | `E2E_PROJECT` | Yes | DO project to assign droplets to | Defaults: region `nyc1`, image `ubuntu-24-04-x64`, size `s-1vcpu-512mb-10gb` ### Vultr | Variable | Required | Description | |---|---|---| | `E2E_VULTR_API_KEY` | Yes | Vultr API key | | `E2E_VULTR_DNS_ZONE` | Yes | DNS zone hosted at Vultr (e.g. `vultr.example.com`) | Defaults: region `ewr`, image `2136` (Ubuntu 24.04), size `vc2-1c-1gb`
The file /home/david/projects/stirlingbridge/machine/tests/E2E.md has been updated successfully.
export E2E_DO_TOKEN="dop_v1_..." export E2E_SSH_KEY="my-ssh-key" export E2E_DNS_ZONE="test.example.com" export E2E_PROJECT="my-project"
export E2E_DO_TOKEN="dop_v1_..." export E2E_SSH_KEY="my-ssh-key" export E2E_DO_DNS_ZONE="do.example.com" export E2E_PROJECT="my-project"
The file /home/david/projects/stirlingbridge/machine/tests/E2E.md has been updated successfully.
export E2E_PROVIDER="vultr" export E2E_VULTR_API_KEY="..." export E2E_SSH_KEY="my-ssh-key" export E2E_DNS_ZONE="example.com"
export E2E_PROVIDER="vultr" export E2E_VULTR_API_KEY="..." export E2E_SSH_KEY="my-ssh-key" export E2E_VULTR_DNS_ZONE="vultr.example.com"
The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed. Note: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions.
Good point — Vultr only supports second-level domains for DNS hosting (as already noted in the prerequisites section). Let me fix that example.
export E2E_PROVIDER="vultr" export E2E_VULTR_API_KEY="..." export E2E_SSH_KEY="my-ssh-key" export E2E_DNS_ZONE="example.com"
export E2E_PROVIDER="vultr" export E2E_VULTR_API_KEY="..." export E2E_SSH_KEY="my-ssh-key" export E2E_VULTR_DNS_ZONE="example.com"
The file /home/david/projects/stirlingbridge/machine/tests/E2E.md has been updated successfully.
| DigitalOcean | `E2E_DO_TOKEN` | `E2E_SSH_KEY`, `E2E_DNS_ZONE`, `E2E_PROJECT` | | Vultr | `E2E_VULTR_API_KEY` | `E2E_SSH_KEY`, `E2E_DNS_ZONE` |
| DigitalOcean | `E2E_DO_TOKEN` | `E2E_SSH_KEY`, `E2E_DO_DNS_ZONE`, `E2E_PROJECT` | | Vultr | `E2E_VULTR_API_KEY` | `E2E_SSH_KEY`, `E2E_VULTR_DNS_ZONE` |
The file /home/david/projects/stirlingbridge/machine/tests/E2E.md has been updated successfully.
| `E2E_VULTR_DNS_ZONE` | Yes | DNS zone hosted at Vultr (e.g. `vultr.example.com`) |
| `E2E_VULTR_DNS_ZONE` | Yes | DNS zone hosted at Vultr (e.g. `example.com` — must be a second-level domain) |
The file /home/david/projects/stirlingbridge/machine/tests/E2E.md has been updated successfully.
E2E_VULTR_DNS_ZONE - DNS zone hosted at Vultr (e.g. "vultr.example.com")
E2E_VULTR_DNS_ZONE - DNS zone hosted at Vultr (e.g. "example.com")
The file /home/david/projects/stirlingbridge/machine/tests/test_e2e.py has been updated successfully.
Building machine @ file:///home/david/projects/stirlingbridge/machine
Built machine @ file:///home/david/projects/stirlingbridge/machine
Uninstalled 1 package in 1ms
Installed 1 package in 1ms
All checks passed!diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml
index 483ed34..9d7561e 100644
--- a/.github/workflows/e2e-test.yml
+++ b/.github/workflows/e2e-test.yml
@@ -32,10 +32,11 @@ jobs:
env:
E2E_PROVIDER: ${{ matrix.provider }}
E2E_SSH_KEY: ${{ vars.E2E_SSH_KEY }}
- E2E_DNS_ZONE: ${{ vars.E2E_DNS_ZONE }}
# DigitalOcean credentials
E2E_DO_TOKEN: ${{ secrets.E2E_DO_TOKEN }}
+ E2E_DO_DNS_ZONE: ${{ vars.E2E_DO_DNS_ZONE }}
E2E_PROJECT: ${{ vars.E2E_PROJECT }}
# Vultr credentials
E2E_VULTR_API_KEY: ${{ secrets.E2E_VULTR_API_KEY }}
+ E2E_VULTR_DNS_ZONE: ${{ vars.E2E_VULTR_DNS_ZONE }}
run: uv run pytest tests/test_e2e.py -v -m e2e
diff --git a/tests/E2E.md b/tests/E2E.md
index 0d58055..711f09d 100644
--- a/tests/E2E.md
+++ b/tests/E2E.md
@@ -52,7 +52,6 @@ The Vultr API key has full access by default; there is no scope configuration.
| Variable | Required | Description |
|---|---|---|
| `E2E_SSH_KEY` | Yes | Name of an SSH key registered with the provider |
-| `E2E_DNS_ZONE` | Yes | Provider-managed DNS zone for test records |
| `E2E_REGION` | No | Region slug (default: provider-specific, see below) |
| `E2E_IMAGE` | No | Image slug or ID (default: provider-specific, see below) |
| `E2E_SIZE` | No | Instance size (default: provider-specific, see below) |
@@ -62,6 +61,7 @@ The Vultr API key has full access by default; there is no scope configuration.
| Variable | Required | Description |
|---|---|---|
| `E2E_DO_TOKEN` | Yes | DigitalOcean API token |
+| `E2E_DO_DNS_ZONE` | Yes | DNS zone hosted at DigitalOcean (e.g. `do.example.com`) |
| `E2E_PROJECT` | Yes | DO project to assign droplets to |
Defaults: region `nyc1`, image `ubuntu-24-04-x64`, size `s-1vcpu-512mb-10gb`
@@ -71,6 +71,7 @@ Defaults: region `nyc1`, image `ubuntu-24-04-x64`, size `s-1vcpu-512mb-10gb`
| Variable | Required | Description |
|---|---|---|
| `E2E_VULTR_API_KEY` | Yes | Vultr API key |
+| `E2E_VULTR_DNS_ZONE` | Yes | DNS zone hosted at Vultr (e.g. `example.com` — must be a second-level domain) |
Defaults: region `ewr`, image `2136` (Ubuntu 24.04), size `vc2-1c-1gb`
@@ -81,7 +82,7 @@ Defaults: region `ewr`, image `2136` (Ubuntu 24.04), size `vc2-1c-1gb`
```bash
export E2E_DO_TOKEN="dop_v1_..."
export E2E_SSH_KEY="my-ssh-key"
-export E2E_DNS_ZONE="test.example.com"
+export E2E_DO_DNS_ZONE="do.example.com"
export E2E_PROJECT="my-project"
make test-e2e
@@ -95,7 +96,7 @@ uv run pytest tests/test_e2e.py -v -m e2e
export E2E_PROVIDER="vultr"
export E2E_VULTR_API_KEY="..."
export E2E_SSH_KEY="my-ssh-key"
-export E2E_DNS_ZONE="example.com"
+export E2E_VULTR_DNS_ZONE="example.com"
make test-e2e
# Or directly
@@ -110,8 +111,8 @@ The GitHub Actions workflow (`.github/workflows/e2e-test.yml`) runs e2e tests fo
| Provider | Secrets | Variables |
|---|---|---|
-| DigitalOcean | `E2E_DO_TOKEN` | `E2E_SSH_KEY`, `E2E_DNS_ZONE`, `E2E_PROJECT` |
-| Vultr | `E2E_VULTR_API_KEY` | `E2E_SSH_KEY`, `E2E_DNS_ZONE` |
+| DigitalOcean | `E2E_DO_TOKEN` | `E2E_SSH_KEY`, `E2E_DO_DNS_ZONE`, `E2E_PROJECT` |
+| Vultr | `E2E_VULTR_API_KEY` | `E2E_SSH_KEY`, `E2E_VULTR_DNS_ZONE` |
If credentials for a provider are not configured, that provider's test run will be skipped automatically.
diff --git a/tests/test_e2e.py b/tests/test_e2e.py
index 903a522..8a5915a 100644
--- a/tests/test_e2e.py
+++ b/tests/test_e2e.py
@@ -10,14 +10,14 @@ Provider selection:
Required environment variables (all providers):
E2E_SSH_KEY - Name of an SSH key already registered with the provider
- E2E_DNS_ZONE - DNS zone managed by the provider (e.g. "test.example.com")
-
Required environment variables (DigitalOcean):
E2E_DO_TOKEN - DigitalOcean API token
+ E2E_DO_DNS_ZONE - DNS zone hosted at DigitalOcean (e.g. "do.example.com")
E2E_PROJECT - DO project name to assign droplets to
Required environment variables (Vultr):
- E2E_VULTR_API_KEY - Vultr API key
+ E2E_VULTR_API_KEY - Vultr API key
+ E2E_VULTR_DNS_ZONE - DNS zone hosted at Vultr (e.g. "example.com")
Optional environment variables:
E2E_REGION - Region slug (default: provider-specific)
@@ -55,7 +55,18 @@ _PROVIDER_DEFAULTS = {
_defaults = _PROVIDER_DEFAULTS.get(E2E_PROVIDER, _PROVIDER_DEFAULTS["digital-ocean"])
E2E_SSH_KEY = os.environ.get("E2E_SSH_KEY")
-E2E_DNS_ZONE = os.environ.get("E2E_DNS_ZONE")
+
+# Per-provider DNS zones
+E2E_DO_DNS_ZONE = os.environ.get("E2E_DO_DNS_ZONE")
+E2E_VULTR_DNS_ZONE = os.environ.get("E2E_VULTR_DNS_ZONE")
+
+# Select the DNS zone for the active provider
+if E2E_PROVIDER == "digital-ocean":
+ E2E_DNS_ZONE = E2E_DO_DNS_ZONE
+elif E2E_PROVIDER == "vultr":
+ E2E_DNS_ZONE = E2E_VULTR_DNS_ZONE
+else:
+ E2E_DNS_ZONE = None
E2E_REGION = os.environ.get("E2E_REGION", _defaults["region"])
E2E_IMAGE = os.environ.get("E2E_IMAGE", _defaults["image"])
E2E_SIZE = os.environ.get("E2E_SIZE", _defaults["size"])
@@ -75,17 +86,18 @@ pytestmark = pytest.mark.e2e
_MISSING = []
if not E2E_SSH_KEY:
_MISSING.append("E2E_SSH_KEY")
-if not E2E_DNS_ZONE:
- _MISSING.append("E2E_DNS_ZONE")
-
if E2E_PROVIDER == "digital-ocean":
if not E2E_DO_TOKEN:
_MISSING.append("E2E_DO_TOKEN")
+ if not E2E_DO_DNS_ZONE:
+ _MISSING.append("E2E_DO_DNS_ZONE")
if not E2E_PROJECT:
_MISSING.append("E2E_PROJECT")
elif E2E_PROVIDER == "vultr":
if not E2E_VULTR_API_KEY:
_MISSING.append("E2E_VULTR_API_KEY")
+ if not E2E_VULTR_DNS_ZONE:
+ _MISSING.append("E2E_VULTR_DNS_ZONE")
else:
_MISSING.append(f"E2E_PROVIDER (unknown provider: {E2E_PROVIDER})")Everything looks clean. Here's a summary of the changes:
Workflow (.github/workflows/e2e-test.yml):
- Replaced single E2E_DNS_ZONE with per-provider E2E_DO_DNS_ZONE and E2E_VULTR_DNS_ZONE variables
Test file (tests/test_e2e.py):
- Reads E2E_DO_DNS_ZONE and E2E_VULTR_DNS_ZONE, then selects the right one into E2E_DNS_ZONE based on the active provider
- Missing-variable checks now report the provider-specific variable name
- All downstream usage of E2E_DNS_ZONE continues to work unchanged
Docs (tests/E2E.md):
- Moved DNS zone from "common" to each provider's section
- Vultr example uses example.com (second-level domain only, per their DNS hosting limitation)
- Updated CI variables table
You'll need to update the GitHub environment variables: replace E2E_DNS_ZONE with E2E_DO_DNS_ZONE and E2E_VULTR_DNS_ZONE in the e2e environment settings. Want me to commit this?