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