Skip to content

Commit 3fb1544

Browse files
committed
Add host-side compose TDX E2E suite
1 parent 29e3563 commit 3fb1544

15 files changed

Lines changed: 1740 additions & 11 deletions

File tree

dstack/gateway/dstack-app/bootstrap-cluster.sh

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ echo "Waiting for gateway admin API at $ADMIN_ADDR..."
3333
max_retries=60
3434
retry=0
3535
while [ $retry -lt $max_retries ]; do
36-
if curl -sf "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/Status" >/dev/null 2>&1; then
36+
if curl -sf "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/Admin.Status" >/dev/null 2>&1; then
3737
break
3838
fi
3939
retry=$((retry + 1))
@@ -48,29 +48,45 @@ fi
4848

4949
echo "Admin API ready, bootstrapping configuration..."
5050

51-
# Set ACME URL
52-
if [ "$ACME_STAGING" = "yes" ]; then
51+
# Set ACME URL. ACME_URL is useful for local E2E suites (for example Pebble).
52+
if [ -n "${ACME_URL:-}" ]; then
53+
:
54+
elif [ "$ACME_STAGING" = "yes" ]; then
5355
ACME_URL="https://acme-staging-v02.api.letsencrypt.org/directory"
5456
else
5557
ACME_URL="https://acme-v02.api.letsencrypt.org/directory"
5658
fi
5759

5860
echo "Setting certbot config (ACME URL: $ACME_URL)..."
59-
curl -sf -X POST "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/SetCertbotConfig" \
61+
curl -sf -X POST "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/Admin.SetCertbotConfig" \
6062
-H "Content-Type: application/json" \
6163
-d '{"acme_url":"'"$ACME_URL"'","renew_interval_secs":3600,"renew_before_expiration_secs":864000,"renew_timeout_secs":300}' >/dev/null \
6264
&& echo " Certbot config set" || echo " WARN: failed to set certbot config"
6365

6466
# Create DNS credential if CF_API_TOKEN is provided and no credentials exist yet
6567
if [ -n "$CF_API_TOKEN" ]; then
66-
existing=$(curl -sf "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/ListDnsCredentials" 2>/dev/null)
68+
existing=$(curl -sf "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/Admin.ListDnsCredentials" 2>/dev/null)
6769
cred_count=$(echo "$existing" | jq -r '.credentials | length' 2>/dev/null || echo "0")
6870

6971
if [ "$cred_count" = "0" ]; then
7072
echo "Creating default DNS credential..."
71-
curl -sf -X POST "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/CreateDnsCredential" \
73+
dns_payload=$(jq -cn \
74+
--arg token "$CF_API_TOKEN" \
75+
--arg api_url "${CF_API_URL:-}" \
76+
--argjson ttl "${DNS_TXT_TTL:-60}" \
77+
--argjson max_wait "${MAX_DNS_WAIT:-300}" \
78+
'{
79+
name: "cloudflare",
80+
provider_type: "cloudflare",
81+
cf_api_token: $token,
82+
set_as_default: true,
83+
dns_txt_ttl: $ttl,
84+
max_dns_wait: $max_wait
85+
}
86+
+ (if $api_url == "" then {} else {cf_api_url: $api_url} end)')
87+
curl -sf -X POST "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/Admin.CreateDnsCredential" \
7288
-H "Content-Type: application/json" \
73-
-d '{"name":"cloudflare","provider_type":"cloudflare","cf_api_token":"'"$CF_API_TOKEN"'","set_as_default":true}' >/dev/null \
89+
-d "$dns_payload" >/dev/null \
7490
&& echo " DNS credential created" || echo " WARN: failed to create DNS credential"
7591
else
7692
echo " DNS credentials already exist ($cred_count), skipping"
@@ -81,12 +97,12 @@ fi
8197

8298
# Add ZT-Domain if SRV_DOMAIN is provided and domain doesn't exist yet
8399
if [ -n "$SRV_DOMAIN" ]; then
84-
existing=$(curl -sf "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/ListZtDomains" 2>/dev/null)
100+
existing=$(curl -sf "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/Admin.ListZtDomains" 2>/dev/null)
85101
has_domain=$(echo "$existing" | jq -r '.domains[]? | select(.domain=="'"$SRV_DOMAIN"'") | .domain' 2>/dev/null)
86102

87103
if [ -z "$has_domain" ]; then
88104
echo "Adding ZT-Domain: $SRV_DOMAIN..."
89-
curl -sf -X POST "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/AddZtDomain" \
105+
curl -sf -X POST "${AUTH_HEADER[@]}" "http://$ADMIN_ADDR/prpc/Admin.AddZtDomain" \
90106
-H "Content-Type: application/json" \
91107
-d '{"domain":"'"$SRV_DOMAIN"'","port":443,"priority":100}' >/dev/null \
92108
&& echo " ZT-Domain added" || echo " WARN: failed to add ZT-Domain"

dstack/gateway/dstack-app/deploy-to-vmm.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,12 @@ NODE_ID=1
6464
6565
# Whether to use ACME staging (yes/no)
6666
ACME_STAGING=no
67+
# Optional ACME directory override for local E2E (for example Pebble).
68+
# ACME_URL=http://10.0.2.2:14000/dir
69+
# Optional Cloudflare API override for local E2E mock.
70+
# CF_API_URL=http://10.0.2.2:18080/client/v4
71+
# DNS_TXT_TTL=60
72+
# MAX_DNS_WAIT=300
6773
6874
# Networking mode: bridge or user (default: user)
6975
# NET_MODE=bridge

dstack/key-provider-build/Dockerfile.key-provider

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,20 @@ RUN git clone https://github.com/MoeMahhouk/gramine-sealing-key-provider && \
5959
WORKDIR /gramine-sealing-key-provider
6060
COPY Cargo.lock .
6161

62-
# Build key provider, generate keys, and build manifest
62+
# Build key provider, generate keys, and build manifest.
63+
#
64+
# Do not use `gramine-sgx-gen-private-key` here. On some hosts the Python
65+
# cryptography/OpenSSL stack in the Gramine image fails while generating the
66+
# RSA key with a generic "Unknown OpenSSL error". Generate the exact same kind
67+
# of Gramine signing key directly with OpenSSL instead:
68+
# - 3072-bit RSA
69+
# - public exponent 3
70+
# - traditional PEM accepted by gramine-sgx-sign
6371
RUN make target/release/gramine-sealing-key-provider && \
64-
gramine-sgx-gen-private-key && \
72+
mkdir -p /root/.config/gramine && \
73+
chmod 0700 /root/.config /root/.config/gramine && \
74+
openssl genrsa -traditional -3 -out /root/.config/gramine/enclave-key.pem 3072 && \
75+
chmod 0600 /root/.config/gramine/enclave-key.pem && \
6576
make RUST_LOG=info
6677

6778
RUN gramine-sgx-sigstruct-view --output-format json gramine-sealing-key-provider.sig
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# SPDX-FileCopyrightText: © 2026 Phala Network <dstack@phala.network>
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
# Directory containing unpacked dstack guest images: <store>/<image-name>/{bzImage,initramfs...,rootfs...,digest.txt}
5+
# In meta-dstack this is usually ../../../build/images from this directory, or an absolute path.
6+
DSTACK_E2E_IMAGE_STORE=../../../build/images
7+
DSTACK_E2E_IMAGE_NAME=dstack-0.6.0
8+
DSTACK_E2E_PLATFORM=tdx
9+
10+
# App image used inside the only CVM launched by this suite.
11+
DSTACK_E2E_APP_IMAGE=nginx:alpine
12+
13+
# Mock ACME/DNS + externally visible test domain for Gateway SNI.
14+
DSTACK_E2E_BASE_DOMAIN=e2e.test
15+
DSTACK_E2E_PEBBLE_IMAGE=kvin/pebble:latest
16+
17+
# Host ports. Defaults are chosen to avoid the common dstackup ports.
18+
DSTACK_E2E_VMM_PORT=29080
19+
DSTACK_E2E_AUTH_PORT=28011
20+
DSTACK_E2E_KMS_HOST_PORT=28082
21+
DSTACK_E2E_GATEWAY_RPC_HOST_PORT=28000
22+
DSTACK_E2E_GATEWAY_ADMIN_HOST_PORT=28001
23+
DSTACK_E2E_GATEWAY_PROXY_HOST_PORT=28443
24+
DSTACK_E2E_GATEWAY_WG_HOST_PORT=28120
25+
DSTACK_E2E_KEY_PROVIDER_PORT=13443
26+
DSTACK_E2E_HOST_API_PORT=20011
27+
DSTACK_E2E_MOCK_CF_HTTP_PORT=38080
28+
DSTACK_E2E_PEBBLE_HTTP_PORT=34000
29+
DSTACK_E2E_PEBBLE_MGMT_PORT=35000
30+
31+
# Dev-compose gateway policy. "any" matches the tdxlab host-side gateway mode:
32+
# app CVMs skip gateway app-id pinning but still use KMS authorization for apps.
33+
DSTACK_E2E_GATEWAY_APP_ID=any
34+
DSTACK_E2E_GATEWAY_WG_INTERFACE=wg-ds-e2e
35+
DSTACK_E2E_GATEWAY_WG_IP=10.8.0.1/16
36+
DSTACK_E2E_GATEWAY_WG_RESERVED_NET=10.8.0.1/32
37+
DSTACK_E2E_GATEWAY_WG_CLIENT_RANGE=10.8.0.0/18
38+
39+
# VMM/QEMU knobs.
40+
DSTACK_E2E_CID_START=15000
41+
DSTACK_E2E_QGS_PORT=4050
42+
DSTACK_E2E_QEMU_PATH=/usr/bin/qemu-system-x86_64
43+
44+
# SGX QCNL config used by AESMD. On hosts with a local PCCS, point this at the
45+
# host config, e.g. /etc/sgx_default_qcnl.conf.
46+
DSTACK_E2E_QCNL_CONF=../../dstack/key-provider-build/sgx_default_qcnl.conf
47+
48+
# Runner behaviour.
49+
DSTACK_E2E_CLEAN_START=true
50+
DSTACK_E2E_CLEANUP_AFTER=false
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.env
2+
/state/*
3+
!/state/.gitkeep
4+
__pycache__/
5+
*.pyc
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# dstack full-stack compose E2E suite
2+
3+
This directory contains a Docker Compose based test suite that mirrors the
4+
`tdxlab`/`dstack-k8s deploy/compose` shape: host-side services run as normal
5+
processes/containers, and QEMU is used only for the real app CVM under test.
6+
7+
```text
8+
docker compose
9+
├─ mock-cf-dns-api # tiny Cloudflare API + TXT DNS mock
10+
├─ pebble # ACME test server (HTTP-capable custom Pebble image)
11+
├─ aesmd + local-keyprovider
12+
├─ dstack-auth # KMS auth webhook backed by auth-allowlist.json
13+
├─ dstack-kms # host-side dev KMS, not a CVM
14+
├─ dstack-gateway # host-side dev Gateway, not a CVM
15+
├─ dstack-vmm # launches real TDX/SNP app CVMs via QEMU
16+
└─ runner
17+
├─ configures Gateway certbot to use mock CF + Pebble
18+
├─ deploys nginx test app CVM with kms_enabled=true + gateway_enabled=true
19+
└─ verifies HTTPS Gateway -> app routing
20+
```
21+
22+
It is intended for a **real TDX/SGX host** (or SEV-SNP host after setting the
23+
platform/image knobs). It is not a pure unit-test environment; QEMU still starts
24+
a confidential VM for the app.
25+
26+
## Prerequisites
27+
28+
1. From this directory, build the host binaries from the dstack checkout:
29+
30+
```bash
31+
cargo build --release \
32+
--manifest-path ../../dstack/Cargo.toml \
33+
-p dstack-cli -p dstack-auth -p dstack-vmm -p dstack-kms \
34+
-p dstack-gateway -p supervisor
35+
```
36+
37+
2. Make sure the host has the required devices/services:
38+
39+
- `/dev/kvm`
40+
- Intel TDX support and QGS for TDX (`DSTACK_E2E_QGS_PORT`, default `4050`)
41+
- `/dev/sgx_enclave` and `/dev/sgx_provision` for the local key provider
42+
- an SGX QCNL config that works on the host. By default the suite uses
43+
`../../dstack/key-provider-build/sgx_default_qcnl.conf`; on hosts with a local
44+
PCCS, set `DSTACK_E2E_QCNL_CONF=/etc/sgx_default_qcnl.conf`.
45+
- `/dev/vhost-vsock` for guest↔host vsock services
46+
- a TDX-capable `qemu-system-x86_64` available inside the runtime container.
47+
The default runtime image follows `../dstack-k8s/deploy/compose` and
48+
installs QEMU from `ppa:kobuk-team/tdx-release`.
49+
- WireGuard kernel support on the host. The Gateway service runs privileged
50+
with host networking and creates `DSTACK_E2E_GATEWAY_WG_INTERFACE`.
51+
52+
3. Provide an unpacked guest image store. From `meta-dstack` this is usually
53+
`../../../build/images`; otherwise copy `.env.example` and set:
54+
55+
```bash
56+
cp .env.example .env
57+
$EDITOR .env
58+
```
59+
60+
No Gateway image publishing is needed in this architecture: `dstack-gateway` and
61+
`dstack-kms` are run from the local `target/release/` binaries on the host side.
62+
63+
## Run
64+
65+
From this directory, run:
66+
67+
```bash
68+
DOCKER_BUILDKIT=0 docker compose --env-file .env -f compose.yml up --build --abort-on-container-exit runner
69+
```
70+
71+
For iterative debugging, keep the stack running:
72+
73+
```bash
74+
DOCKER_BUILDKIT=0 docker compose --env-file .env -f compose.yml up --build
75+
```
76+
77+
The runner leaves the app VM running by default for inspection. Set
78+
`DSTACK_E2E_CLEANUP_AFTER=true` to remove suite VMs at the end. Stale VMs whose
79+
names start with `DSTACK_E2E_NAME_PREFIX` are removed at the start when
80+
`DSTACK_E2E_CLEAN_START=true`.
81+
82+
## What is verified
83+
84+
The runner checks:
85+
86+
1. Host-side KMS is reachable over TLS.
87+
2. Host-side Gateway Admin API accepts configuration.
88+
3. Gateway obtains a wildcard certificate for `*.DSTACK_E2E_BASE_DOMAIN` from
89+
Pebble using the mock Cloudflare DNS API.
90+
4. VMM API becomes reachable.
91+
5. A real nginx app CVM boots with `kms_enabled=true` and `gateway_enabled=true`.
92+
6. `curl -k` through the Gateway SNI route returns the nginx welcome page.
93+
94+
Artifacts are written under `state/work/`.
95+
96+
## Notes / flexibility gaps
97+
98+
- This is a dev compose KMS/Gateway setup. KMS and Gateway key material is
99+
generated under `state/`; the allowlist returns `gatewayAppId = "any"`, which
100+
matches the host-side tdxlab-style deployment and avoids requiring Gateway to
101+
be a CVM.
102+
- `dstackup install` is systemd-oriented; this suite renders `vmm.toml`,
103+
`kms.toml`, `gateway.toml`, and `auth-allowlist.json` itself.
104+
- Running VMM in a container depends on the container image having a QEMU build
105+
that supports the target TEE platform. The runtime Dockerfile uses Intel's
106+
kobuk-team TDX PPA for QEMU, matching the dstack-k8s compose deployment.
107+
- Host API is vsock-only by design. The suite therefore uses privileged host
108+
networking and a separate `DSTACK_E2E_HOST_API_PORT` to avoid conflicts with
109+
other VMM instances.

0 commit comments

Comments
 (0)