Skip to content

Commit cdcffa8

Browse files
committed
test(e2e): add Hetzner install suite + harden work-dir permissions
Provision ephemeral Hetzner VMs via curl-based API client (EphemeralCluster RAII guard), run `coolify init apply`, then assert wg0/podman/firewall/coold state over SSH. Suite is fully `#[ignore]`; env loaded from e2e-tests/.env. Also tighten file permissions created by builder and coold: - work_root and per-request dirs created with mode 0o700 - events.ndjson and request.json written with mode 0o600
1 parent 30d9113 commit cdcffa8

11 files changed

Lines changed: 1377 additions & 7 deletions

File tree

.dockerignore

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
target
2+
Cargo.lock.bak
3+
.git
4+
5+
# Never ship local secrets into an image.
6+
.env
7+
!.env.example

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
/target
22
Cargo.lock.bak
3+
4+
# e2e-tests may keep a local .env with Hetzner tokens etc.
5+
.env
6+
!.env.example

builder/src/main.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
2727
use std::fs::{File, OpenOptions};
2828
use std::io::Write;
29+
use std::os::unix::fs::OpenOptionsExt;
2930
use std::path::{Path, PathBuf};
3031
use std::process::ExitCode;
3132

@@ -66,7 +67,14 @@ impl<'a> ProgressSink for DualSink<'a> {
6667

6768
fn write_json_atomic(path: &Path, bytes: &[u8]) {
6869
let tmp = path.with_extension("json.tmp");
69-
if std::fs::write(&tmp, bytes).is_ok() {
70+
let ok = OpenOptions::new()
71+
.create(true)
72+
.write(true)
73+
.truncate(true)
74+
.mode(0o600)
75+
.open(&tmp)
76+
.and_then(|mut f| f.write_all(bytes).and_then(|_| f.sync_all()));
77+
if ok.is_ok() {
7078
let _ = std::fs::rename(tmp, path);
7179
}
7280
}
@@ -103,6 +111,7 @@ async fn main() -> ExitCode {
103111
let mut events = match OpenOptions::new()
104112
.create(true)
105113
.append(true)
114+
.mode(0o600)
106115
.open(work_dir.join("events.ndjson"))
107116
{
108117
Ok(f) => f,

coold/src/builder/mod.rs

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,13 @@
1717
//! children down in the same sweep.
1818
1919
use std::collections::HashMap;
20+
use std::os::unix::fs::{DirBuilderExt, PermissionsExt};
2021
use std::path::PathBuf;
2122
use std::process::Stdio;
2223
use std::sync::Arc;
2324

2425
use serde::{Deserialize, Serialize};
25-
use tokio::io::{AsyncBufReadExt, BufReader};
26+
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
2627
use tokio::process::Command;
2728
use tokio::sync::{mpsc, Mutex, Semaphore};
2829
use tracing::{info, warn};
@@ -79,7 +80,12 @@ impl BuilderCtx {
7980
}
8081

8182
pub async fn ensure_work_root(&self) -> std::io::Result<()> {
82-
tokio::fs::create_dir_all(&self.work_root).await
83+
tokio::fs::create_dir_all(&self.work_root).await?;
84+
tokio::fs::set_permissions(
85+
&self.work_root,
86+
std::fs::Permissions::from_mode(0o700),
87+
)
88+
.await
8389
}
8490

8591
/// Resume or reap builder transient units left by a prior coold run.
@@ -235,14 +241,29 @@ impl BuilderCtx {
235241

236242
async fn run_build(&self, request_id: &str, req: BuildRequest) -> Result<BuildResult, BuildError> {
237243
let work_dir = self.work_root.join(request_id);
238-
tokio::fs::create_dir_all(&work_dir)
239-
.await
240-
.map_err(|e| build_err(500, "setup", format!("mkdir work: {e}")))?;
244+
let wd = work_dir.clone();
245+
tokio::task::spawn_blocking(move || {
246+
std::fs::DirBuilder::new()
247+
.recursive(true)
248+
.mode(0o700)
249+
.create(&wd)
250+
})
251+
.await
252+
.map_err(|e| build_err(500, "setup", format!("mkdir join: {e}")))?
253+
.map_err(|e| build_err(500, "setup", format!("mkdir work: {e}")))?;
241254

242255
let req_path = work_dir.join("request.json");
243256
let req_json = serde_json::to_vec(&SubprocessRequest::from_proto(&req))
244257
.map_err(|e| build_err(500, "setup", format!("encode request: {e}")))?;
245-
tokio::fs::write(&req_path, &req_json)
258+
let mut f = tokio::fs::OpenOptions::new()
259+
.create(true)
260+
.write(true)
261+
.truncate(true)
262+
.mode(0o600)
263+
.open(&req_path)
264+
.await
265+
.map_err(|e| build_err(500, "setup", format!("open request.json: {e}")))?;
266+
f.write_all(&req_json)
246267
.await
247268
.map_err(|e| build_err(500, "setup", format!("write request.json: {e}")))?;
248269

e2e-tests/.env.example

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Copy to .env and fill in. Never committed — root .gitignore covers it.
2+
# Values only apply when not already exported in the shell.
3+
4+
# ─── Install suite (Hetzner-provisioned) ─────────────────────────────────────
5+
HETZNER_TOKEN=
6+
HETZNER_PROJECT=my-coold-e2e
7+
SSH_KEY=/Users/you/.ssh/id_ed25519
8+
COOLIFY_BIN=coolify
9+
# HETZNER_LOCATION=nbg1
10+
# HETZNER_IMAGE=ubuntu-24.04
11+
# HETZNER_SERVER_TYPE=cx23
12+
13+
# ─── Builder suite (pre-existing cluster) ────────────────────────────────────
14+
# BUILDER_HOST=
15+
# COOLD_ONLY_HOST=
16+
# BUILDER_MGMT=
17+
# COOLD_ONLY_MGMT=
18+
# CENTRAL_HOST=
19+
# SSH_USER=root
20+
21+
# ─── Sweeper opt-in ──────────────────────────────────────────────────────────
22+
# CONFIRM_SWEEP=1

e2e-tests/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,9 @@ serde_json = { workspace = true }
1515
name = "builder"
1616
path = "tests/builder.rs"
1717

18+
[[test]]
19+
name = "install"
20+
path = "tests/install.rs"
21+
1822
[lib]
1923
path = "src/lib.rs"

e2e-tests/E2E.md

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
# E2E Tests — Run Book
2+
3+
All tests are `#[ignore]` and require live infrastructure. Always pass `--ignored --nocapture --test-threads=1`.
4+
5+
## `.env` support
6+
7+
Harness auto-loads `e2e-tests/.env` if present (shell-exported vars still win). Copy the template:
8+
9+
```bash
10+
cp e2e-tests/.env.example e2e-tests/.env
11+
$EDITOR e2e-tests/.env
12+
```
13+
14+
`.env` is gitignored + dockerignored.
15+
16+
## Compile only
17+
18+
```bash
19+
cargo test -p e2e-tests --no-run
20+
```
21+
22+
## Suite 1 — `builder.rs` (pre-installed cluster)
23+
24+
Requires an already-bootstrapped cluster. Env vars:
25+
26+
```bash
27+
export BUILDER_HOST=<ssh-addr-of-builder-host>
28+
export COOLD_ONLY_HOST=<ssh-addr-of-coold-only-host>
29+
export BUILDER_MGMT=<wg0-ip-of-builder-host>
30+
export COOLD_ONLY_MGMT=<wg0-ip-of-coold-only-host>
31+
export CENTRAL_HOST=<ssh-addr-of-central>
32+
export SSH_KEY=~/.ssh/<key>
33+
# optional:
34+
export SSH_USER=root
35+
```
36+
37+
### Run all in suite
38+
39+
```bash
40+
cargo test -p e2e-tests --test builder -- --ignored --nocapture --test-threads=1
41+
```
42+
43+
### Individual tests
44+
45+
```bash
46+
cargo test -p e2e-tests --test builder pin_to_builder_host -- --ignored --nocapture
47+
cargo test -p e2e-tests --test builder pin_to_coold_only_host_returns_503 -- --ignored --nocapture
48+
cargo test -p e2e-tests --test builder unknown_host_id_returns_503 -- --ignored --nocapture
49+
cargo test -p e2e-tests --test builder load_balance_picks_builder_host -- --ignored --nocapture
50+
cargo test -p e2e-tests --test builder build_cancel_emits_stage_cancel -- --ignored --nocapture
51+
cargo test -p e2e-tests --test builder coold_restart_adopts_in_flight_build -- --ignored --nocapture
52+
```
53+
54+
## Suite 2 — `install.rs` (Hetzner-provisioned)
55+
56+
Provisions VMs via Hetzner Cloud API, runs `coolify init apply`, asserts networking, destroys VMs on drop. Env vars:
57+
58+
```bash
59+
export HETZNER_TOKEN=<project-scoped-token>
60+
export HETZNER_PROJECT=<label-value-for-cleanup-filter>
61+
export SSH_KEY=~/.ssh/<key> # privkey path; .pub derived or read
62+
export COOLIFY_BIN=$(which coolify) # local Go CLI path (default "coolify")
63+
# optional:
64+
export HETZNER_LOCATION=nbg1
65+
export HETZNER_IMAGE=ubuntu-24.04
66+
export HETZNER_SERVER_TYPE=cx23
67+
```
68+
69+
### Both in parallel (each test provisions its own VMs)
70+
71+
Name filter `install_` selects install_single_host + install_two_hosts and skips `cleanup_leaked_hetzner` (which would otherwise race the sweeper against live VMs).
72+
73+
```bash
74+
cargo test -p e2e-tests --test install install_ -- --ignored --nocapture
75+
```
76+
77+
### Single-host only
78+
79+
```bash
80+
cargo test -p e2e-tests --test install install_single_host -- --ignored --nocapture
81+
```
82+
83+
### Two-host only
84+
85+
```bash
86+
cargo test -p e2e-tests --test install install_two_hosts -- --ignored --nocapture
87+
```
88+
89+
### Leaked-resource sweeper
90+
91+
Deletes every Hetzner server + ssh_key labeled `coolify-e2e=1` in the project. Use after a ctrl-c / crash left VMs behind. Extra `CONFIRM_SWEEP=1` gate — without it the test is a no-op, so it's safe to leave in the suite.
92+
93+
```bash
94+
CONFIRM_SWEEP=1 cargo test -p e2e-tests --test install cleanup_leaked_hetzner -- --ignored --nocapture
95+
```
96+
97+
## Manual spot-checks (post-install failure triage)
98+
99+
```bash
100+
ssh root@<ip> wg show wg0
101+
ssh root@<ip> iptables -S COOLIFY-INTRA
102+
ssh root@<ip> nft list table bridge coolify_bridge
103+
ssh root@<ipA> podman exec e2e-a ping -c1 <ipB>
104+
```

0 commit comments

Comments
 (0)