Skip to content

Commit 71f82ff

Browse files
committed
feat(components/execd): add smoke api script
1 parent b7d564a commit 71f82ff

File tree

2 files changed

+200
-3
lines changed

2 files changed

+200
-3
lines changed

.github/workflows/execd-test.yml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ jobs:
7878
./tests/smoke.sh
7979
8080
sleep 5
81-
cat execd.log
82-
cat /tmp/jupyter.log
83-
curl -v localhost:44772/ping
81+
python3 tests/smoke_api.py
82+
- name: Show logs
83+
if: always()
84+
run: cat execd.log
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
#!/usr/bin/env python3
2+
3+
# Copyright 2025 Alibaba Group Holding Ltd.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License");
6+
# you may not use this file except in compliance with the License.
7+
# You may obtain a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS,
13+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
# See the License for the specific language governing permissions and
15+
# limitations under the License.
16+
17+
"""
18+
Simple smoke tests for execd command and filesystem APIs.
19+
20+
Prerequisites:
21+
- execd server running locally (default http://localhost:44772)
22+
- Optional: set env BASE_URL to override
23+
- Optional: set env API_TOKEN if server expects X-EXECD-ACCESS-TOKEN
24+
"""
25+
26+
import json
27+
import os
28+
import sys
29+
import time
30+
import uuid
31+
32+
import requests
33+
34+
BASE_URL = os.environ.get("BASE_URL", "http://localhost:44772").rstrip("/")
35+
API_TOKEN = os.environ.get("API_TOKEN")
36+
37+
HEADERS = {}
38+
if API_TOKEN:
39+
HEADERS["X-EXECD-ACCESS-TOKEN"] = API_TOKEN
40+
41+
session = requests.Session()
42+
session.headers.update(HEADERS)
43+
44+
45+
def expect(cond: bool, msg: str):
46+
if not cond:
47+
raise SystemExit(msg)
48+
49+
50+
def sse_get_command_id() -> str:
51+
url = f"{BASE_URL}/command"
52+
payload = {"command": "echo smoke-command && sleep 1", "background": True}
53+
with session.post(url, json=payload, stream=True, timeout=15) as resp:
54+
expect(resp.status_code == 200, f"SSE start failed: {resp.status_code} {resp.text}")
55+
for line in resp.iter_lines():
56+
if not line or not line.startswith(b"data:"):
57+
# controller emits raw JSON lines without SSE 'data:' prefix
58+
try:
59+
data = json.loads(line.decode())
60+
except Exception:
61+
continue
62+
else:
63+
data = json.loads(line[len(b"data:") :].decode())
64+
if data.get("type") == "init":
65+
cmd_id = data.get("text")
66+
expect(cmd_id, "missing command id in init event")
67+
return cmd_id
68+
raise SystemExit("Failed to obtain command id from SSE")
69+
70+
71+
def wait_status(cmd_id: str, timeout: float = 15.0) -> dict:
72+
url = f"{BASE_URL}/command/status/{cmd_id}"
73+
deadline = time.time() + timeout
74+
last = None
75+
while time.time() < deadline:
76+
r = session.get(url, timeout=5)
77+
expect(r.status_code == 200, f"status failed: {r.status_code} {r.text}")
78+
last = r.json()
79+
if not last.get("running", True):
80+
return last
81+
time.sleep(0.3)
82+
return last
83+
84+
85+
def fetch_logs(cmd_id: str, cursor: int = 0):
86+
url = f"{BASE_URL}/command/{cmd_id}/logs"
87+
r = session.get(url, params={"cursor": cursor}, timeout=10)
88+
expect(r.status_code == 200, f"logs failed: {r.status_code} {r.text}")
89+
return r.text, r.headers.get("EXECD-COMMANDS-TAIL-CURSOR")
90+
91+
92+
def upload_and_download():
93+
tmp_dir = f"/tmp/execd-smoke-{uuid.uuid4().hex}"
94+
path = f"{tmp_dir}/hello.txt"
95+
metadata = json.dumps({"path": path})
96+
files = {
97+
"metadata": ("metadata", metadata, "application/json"),
98+
"file": ("file", b"hello execd\n", "application/octet-stream"),
99+
}
100+
up = session.post(f"{BASE_URL}/files/upload", files=files, timeout=10)
101+
expect(up.status_code == 200, f"upload failed: {up.status_code} {up.text}")
102+
103+
down = session.get(f"{BASE_URL}/files/download", params={"path": path}, timeout=10)
104+
expect(down.status_code == 200, f"download failed: {down.status_code} {down.text}")
105+
expect(down.content == b"hello execd\n", "downloaded content mismatch")
106+
107+
108+
def filesystem_smoke():
109+
base_dir = f"/tmp/execd-smoke-{uuid.uuid4().hex}"
110+
sub_dir = f"{base_dir}/sub"
111+
file_path = f"{sub_dir}/hello.txt"
112+
renamed_path = f"{sub_dir}/hello_renamed.txt"
113+
114+
# create dirs
115+
mk = session.post(f"{BASE_URL}/directories", json={sub_dir: {"mode": 0}}, timeout=10)
116+
expect(mk.status_code == 200, f"mkdir failed: {mk.status_code} {mk.text}")
117+
118+
# upload a file
119+
metadata = json.dumps({"path": file_path})
120+
files = {
121+
"metadata": ("metadata", metadata, "application/json"),
122+
"file": ("file", b"hello execd\n", "application/octet-stream"),
123+
}
124+
up = session.post(f"{BASE_URL}/files/upload", files=files, timeout=10)
125+
expect(up.status_code == 200, f"upload failed: {up.status_code} {up.text}")
126+
127+
# get info
128+
info = session.get(f"{BASE_URL}/files/info", params={"path": [file_path]}, timeout=10)
129+
expect(info.status_code == 200, f"info failed: {info.status_code} {info.text}")
130+
131+
# search
132+
search = session.get(f"{BASE_URL}/files/search", params={"path": base_dir, "pattern": "*.txt"}, timeout=10)
133+
expect(search.status_code == 200, f"search failed: {search.status_code} {search.text}")
134+
expect(any(f.get("path") == file_path for f in search.json()), "search did not find file")
135+
136+
# replace content
137+
rep = session.post(
138+
f"{BASE_URL}/files/replace",
139+
json={file_path: {"old": "hello", "new": "hi"}},
140+
timeout=10,
141+
)
142+
expect(rep.status_code == 200, f"replace failed: {rep.status_code} {rep.text}")
143+
144+
# download to verify replace
145+
down = session.get(f"{BASE_URL}/files/download", params={"path": file_path}, timeout=10)
146+
expect(down.status_code == 200, f"download failed: {down.status_code} {down.text}")
147+
expect(down.content == b"hi execd\n", "replace content mismatch")
148+
149+
# chmod (mode only)
150+
chmod = session.post(f"{BASE_URL}/files/permissions", json={file_path: {"mode": 644}}, timeout=10)
151+
expect(chmod.status_code == 200, f"chmod failed: {chmod.status_code} {chmod.text}")
152+
153+
# rename
154+
mv = session.post(
155+
f"{BASE_URL}/files/mv",
156+
json=[{"src": file_path, "dest": renamed_path}],
157+
timeout=10,
158+
)
159+
expect(mv.status_code == 200, f"rename failed: {mv.status_code} {mv.text}")
160+
161+
# remove file
162+
rm_file = session.delete(f"{BASE_URL}/files", params={"path": [renamed_path]}, timeout=10)
163+
expect(rm_file.status_code == 200, f"remove file failed: {rm_file.status_code} {rm_file.text}")
164+
165+
# remove dir
166+
rm_dir = session.delete(f"{BASE_URL}/directories", params={"path": [base_dir]}, timeout=10)
167+
expect(rm_dir.status_code == 200, f"remove dir failed: {rm_dir.status_code} {rm_dir.text}")
168+
169+
170+
def main():
171+
print(f"[+] base: {BASE_URL}")
172+
r = session.get(f"{BASE_URL}/ping", timeout=5)
173+
expect(r.status_code == 200, "ping failed")
174+
print("[+] ping ok")
175+
176+
cmd_id = sse_get_command_id()
177+
print(f"[+] command id: {cmd_id}")
178+
179+
status = wait_status(cmd_id)
180+
print(f"[+] status: {status}")
181+
182+
logs, cursor = fetch_logs(cmd_id, cursor=0)
183+
print(f"[+] logs (cursor={cursor}):\n{logs}")
184+
185+
filesystem_smoke()
186+
print("[+] filesystem APIs ok")
187+
188+
print("[+] smoke tests PASS")
189+
190+
191+
if __name__ == "__main__":
192+
try:
193+
main()
194+
except SystemExit as exc:
195+
print(f"[!] smoke tests FAIL: {exc}", file=sys.stderr)
196+
sys.exit(1)

0 commit comments

Comments
 (0)