accli 2.0.7__tar.gz → 2.0.13__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {accli-2.0.7 → accli-2.0.13}/PKG-INFO +1 -1
- {accli-2.0.7 → accli-2.0.13}/accli/AcceleratorTaskDispatcher.py +74 -24
- {accli-2.0.7 → accli-2.0.13}/accli/_version.py +1 -1
- {accli-2.0.7 → accli-2.0.13}/accli/cli.py +223 -48
- {accli-2.0.7 → accli-2.0.13}/accli/gui.py +74 -31
- {accli-2.0.7 → accli-2.0.13}/accli/token.py +8 -0
- {accli-2.0.7 → accli-2.0.13}/accli.egg-info/PKG-INFO +1 -1
- {accli-2.0.7 → accli-2.0.13}/accli.egg-info/SOURCES.txt +2 -1
- accli-2.0.13/tests/test_mount_lifecycle.py +138 -0
- {accli-2.0.7 → accli-2.0.13}/LICENSE +0 -0
- {accli-2.0.7 → accli-2.0.13}/README.md +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/AcceleratorJobProjectService.py +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/AcceleratorTerminalCliProjectService.py +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/CsvRegionalTimeseriesValidator.py +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/__init__.py +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/__main__.py +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/cleanup-nfs.ps1 +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/common.py +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/mount_downloader.py +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/nfs_watcher.py +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/refresh_explorer.ps1 +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli/setup-nfs.ps1 +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli.egg-info/dependency_links.txt +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli.egg-info/entry_points.txt +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli.egg-info/requires.txt +0 -0
- {accli-2.0.7 → accli-2.0.13}/accli.egg-info/top_level.txt +0 -0
- {accli-2.0.7 → accli-2.0.13}/pyproject.toml +0 -0
- {accli-2.0.7 → accli-2.0.13}/setup.cfg +0 -0
|
@@ -4,6 +4,9 @@ import hashlib
|
|
|
4
4
|
import shutil
|
|
5
5
|
import tempfile
|
|
6
6
|
import fnmatch
|
|
7
|
+
import concurrent.futures
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
7
10
|
from typing import Optional, Dict, List
|
|
8
11
|
|
|
9
12
|
import requests
|
|
@@ -50,9 +53,13 @@ def get_file_sha1(file_path):
|
|
|
50
53
|
|
|
51
54
|
|
|
52
55
|
def copy_tree(src, dst, include=None, exclude=None, root_src=None):
|
|
53
|
-
"""Recursively copy from src to dst, excluding .git folders."""
|
|
56
|
+
"""Recursively copy from src to dst, excluding .git folders. Includes override excludes."""
|
|
54
57
|
if root_src is None:
|
|
55
58
|
root_src = src
|
|
59
|
+
|
|
60
|
+
excludes = [e.strip() for e in exclude.split(',') if e.strip()] if exclude else []
|
|
61
|
+
includes = [i.strip() for i in include.split(',') if i.strip()] if include else []
|
|
62
|
+
|
|
56
63
|
for item in os.listdir(src):
|
|
57
64
|
src_path = os.path.join(src, item)
|
|
58
65
|
dst_path = os.path.join(dst, item)
|
|
@@ -61,24 +68,54 @@ def copy_tree(src, dst, include=None, exclude=None, root_src=None):
|
|
|
61
68
|
if os.path.isdir(src_path):
|
|
62
69
|
if item == '.git':
|
|
63
70
|
continue
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
71
|
+
|
|
72
|
+
is_excluded = any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(item, p) for p in excludes)
|
|
73
|
+
# Traverse even if excluded as long as includes exist, to find exceptions
|
|
74
|
+
if is_excluded and not includes:
|
|
75
|
+
continue
|
|
76
|
+
|
|
68
77
|
os.makedirs(dst_path, exist_ok=True)
|
|
69
78
|
copy_tree(src_path, dst_path, include, exclude, root_src)
|
|
70
79
|
else:
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
80
|
+
is_excluded = any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(item, p) for p in excludes)
|
|
81
|
+
is_included = any(fnmatch.fnmatch(rel_path, p) or fnmatch.fnmatch(item, p) for p in includes)
|
|
82
|
+
|
|
83
|
+
if is_included:
|
|
84
|
+
pass # Included files override exclude
|
|
85
|
+
elif is_excluded:
|
|
86
|
+
continue # Skipped by exclude
|
|
87
|
+
elif includes and not excludes:
|
|
88
|
+
continue # If ONLY includes list is provided, act as whitelist
|
|
89
|
+
|
|
79
90
|
shutil.copy2(src_path, dst_path)
|
|
80
91
|
|
|
81
92
|
|
|
93
|
+
def _upload_cas_file_worker(args):
|
|
94
|
+
rel_path, file_path, term_cli_project_service, project_slug = args
|
|
95
|
+
file_hash = get_file_sha1(file_path)
|
|
96
|
+
blob_name = f"cas_{file_hash}.blob"
|
|
97
|
+
|
|
98
|
+
print(f"Checking {rel_path} (hash: {file_hash[:8]})...")
|
|
99
|
+
presigned_push_url = term_cli_project_service.get_jobstore_push_url(
|
|
100
|
+
project_slug, blob_name
|
|
101
|
+
)
|
|
102
|
+
if presigned_push_url:
|
|
103
|
+
print(f"Uploading {rel_path}...")
|
|
104
|
+
with open(file_path, 'rb') as f:
|
|
105
|
+
res = requests.put(
|
|
106
|
+
presigned_push_url,
|
|
107
|
+
data=f,
|
|
108
|
+
verify=False,
|
|
109
|
+
)
|
|
110
|
+
res.raise_for_status()
|
|
111
|
+
print(f"Uploaded {rel_path}.")
|
|
112
|
+
else:
|
|
113
|
+
print(f"Skipped {rel_path} (already in jobstore).")
|
|
114
|
+
|
|
115
|
+
mode = os.stat(file_path).st_mode
|
|
116
|
+
return {"path": rel_path, "hash": file_hash, "mode": mode}
|
|
117
|
+
|
|
118
|
+
|
|
82
119
|
@lru_cache(maxsize=None)
|
|
83
120
|
def push_folder_job(directory, include=None, exclude=None):
|
|
84
121
|
server_url = get_server_url()
|
|
@@ -97,19 +134,32 @@ def push_folder_job(directory, include=None, exclude=None):
|
|
|
97
134
|
if os.path.isfile(f'{repo_dir}/wkube.py'):
|
|
98
135
|
os.remove(f'{repo_dir}/wkube.py')
|
|
99
136
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
137
|
+
manifest = {"type": "cas_manifest", "files": []}
|
|
138
|
+
|
|
139
|
+
upload_args = []
|
|
140
|
+
for root, dirs, files in os.walk(repo_dir):
|
|
141
|
+
for file in files:
|
|
142
|
+
file_path = os.path.join(root, file)
|
|
143
|
+
rel_path = Path(file_path).relative_to(repo_dir).as_posix()
|
|
144
|
+
upload_args.append((rel_path, file_path, term_cli_project_service, project_slug))
|
|
145
|
+
|
|
146
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=30) as executor:
|
|
147
|
+
for result in executor.map(_upload_cas_file_worker, upload_args):
|
|
148
|
+
manifest["files"].append(result)
|
|
149
|
+
|
|
150
|
+
manifest_path = os.path.join(repo_dir, "manifest.json")
|
|
151
|
+
with open(manifest_path, "w") as f:
|
|
152
|
+
json.dump(manifest, f)
|
|
153
|
+
|
|
154
|
+
manifest_hash = get_file_sha1(manifest_path)
|
|
155
|
+
manifest_name = f"manifest_{manifest_hash}.json"
|
|
156
|
+
|
|
107
157
|
presigned_push_url = term_cli_project_service.get_jobstore_push_url(
|
|
108
|
-
project_slug,
|
|
158
|
+
project_slug, manifest_name
|
|
109
159
|
)
|
|
110
|
-
|
|
160
|
+
|
|
111
161
|
if presigned_push_url:
|
|
112
|
-
with open(
|
|
162
|
+
with open(manifest_path, 'rb') as f:
|
|
113
163
|
res = requests.put(
|
|
114
164
|
presigned_push_url,
|
|
115
165
|
data=f,
|
|
@@ -118,7 +168,7 @@ def push_folder_job(directory, include=None, exclude=None):
|
|
|
118
168
|
res.raise_for_status()
|
|
119
169
|
|
|
120
170
|
shutil.rmtree(repo_dir)
|
|
121
|
-
return f"s3accjobstore://{
|
|
171
|
+
return f"s3accjobstore://{manifest_name}", manifest_hash
|
|
122
172
|
|
|
123
173
|
|
|
124
174
|
class JobDispatchModel(BaseModel):
|
|
@@ -672,6 +672,7 @@ def start_nfs_watcher(mount_point: str, interval: int):
|
|
|
672
672
|
proc = subprocess.Popen(
|
|
673
673
|
[executable, str(watcher_script), str(mount_point), str(interval)],
|
|
674
674
|
creationflags=creationflags,
|
|
675
|
+
stdin=subprocess.DEVNULL,
|
|
675
676
|
stdout=subprocess.DEVNULL,
|
|
676
677
|
stderr=subprocess.DEVNULL,
|
|
677
678
|
close_fds=True
|
|
@@ -722,6 +723,167 @@ def stop_nfs_watcher(mount_point: str):
|
|
|
722
723
|
pass
|
|
723
724
|
|
|
724
725
|
|
|
726
|
+
def cleanup_stuck_windows_mount_processes():
|
|
727
|
+
"""Terminate any orphaned mount.exe / umount.exe instances that block Windows NFS client."""
|
|
728
|
+
import subprocess
|
|
729
|
+
try:
|
|
730
|
+
subprocess.run(["taskkill", "/f", "/im", "mount.exe"], capture_output=True)
|
|
731
|
+
except Exception:
|
|
732
|
+
pass
|
|
733
|
+
try:
|
|
734
|
+
subprocess.run(["taskkill", "/f", "/im", "umount.exe"], capture_output=True)
|
|
735
|
+
except Exception:
|
|
736
|
+
pass
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def is_nfs_daemon_running() -> bool:
|
|
740
|
+
"""Check if hf-mount-nfs.exe is currently running."""
|
|
741
|
+
import subprocess
|
|
742
|
+
try:
|
|
743
|
+
res = subprocess.run(["tasklist", "/FI", "IMAGENAME eq hf-mount-nfs.exe"], capture_output=True, text=True)
|
|
744
|
+
return "hf-mount-nfs.exe" in res.stdout
|
|
745
|
+
except Exception:
|
|
746
|
+
return False
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def is_nfs_kernel_driver_active() -> bool:
|
|
750
|
+
"""Check if the Windows Client for NFS kernel redirector driver (NfsRdr) is loaded and running."""
|
|
751
|
+
import subprocess
|
|
752
|
+
import shutil
|
|
753
|
+
if not shutil.which("mount.exe") and not os.path.isfile("C:\\Windows\\System32\\mount.exe"):
|
|
754
|
+
return False
|
|
755
|
+
try:
|
|
756
|
+
res = subprocess.run(["sc.exe", "query", "NfsRdr"], capture_output=True, text=True)
|
|
757
|
+
if "RUNNING" in res.stdout:
|
|
758
|
+
return True
|
|
759
|
+
# If stopped, try to start it
|
|
760
|
+
subprocess.run(["sc.exe", "start", "NfsRdr"], capture_output=True)
|
|
761
|
+
check = subprocess.run(["sc.exe", "query", "NfsRdr"], capture_output=True, text=True)
|
|
762
|
+
return "RUNNING" in check.stdout
|
|
763
|
+
except Exception:
|
|
764
|
+
return False
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def wait_for_nfs_portmapper(host: str, port: int, timeout: float) -> bool:
|
|
768
|
+
"""Poll until the NFS daemon's portmapper is accepting TCP connections and is stable."""
|
|
769
|
+
import socket
|
|
770
|
+
start = time.time()
|
|
771
|
+
consecutive_success = 0
|
|
772
|
+
while time.time() - start < timeout:
|
|
773
|
+
try:
|
|
774
|
+
with socket.create_connection((host, port), timeout=0.5):
|
|
775
|
+
consecutive_success += 1
|
|
776
|
+
if consecutive_success >= 2:
|
|
777
|
+
time.sleep(1.0)
|
|
778
|
+
return True
|
|
779
|
+
except (socket.error, OSError):
|
|
780
|
+
consecutive_success = 0
|
|
781
|
+
time.sleep(0.3)
|
|
782
|
+
return False
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
def ensure_nfs_daemon_stable(timeout: float) -> bool:
|
|
786
|
+
"""
|
|
787
|
+
Ensure the NFS daemon starts and survives any delayed UMNT packet flushed by Windows NFS client.
|
|
788
|
+
If the daemon exits due to UMNT, re-triggers the Task Scheduler task to start a fresh daemon.
|
|
789
|
+
"""
|
|
790
|
+
import subprocess
|
|
791
|
+
for attempt in range(3):
|
|
792
|
+
if not wait_for_nfs_portmapper("127.0.0.1", 111, timeout):
|
|
793
|
+
return False
|
|
794
|
+
|
|
795
|
+
# Windows NFS client kernel redirector flushes delayed UMNT within 7-8 seconds of portmapper binding
|
|
796
|
+
exited = False
|
|
797
|
+
for _ in range(16):
|
|
798
|
+
time.sleep(0.5)
|
|
799
|
+
if not is_nfs_daemon_running():
|
|
800
|
+
exited = True
|
|
801
|
+
break
|
|
802
|
+
|
|
803
|
+
if not exited:
|
|
804
|
+
# Daemon remained running and stable without being killed by delayed UMNT
|
|
805
|
+
return True
|
|
806
|
+
|
|
807
|
+
# Daemon consumed the queued UMNT packet and terminated cleanly. Re-trigger task!
|
|
808
|
+
time.sleep(1.0)
|
|
809
|
+
subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True)
|
|
810
|
+
|
|
811
|
+
return wait_for_nfs_portmapper("127.0.0.1", 111, timeout)
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def map_windows_network_drive(mount_point_abs, max_attempts: int) -> tuple[bool, str]:
|
|
815
|
+
"""
|
|
816
|
+
Attempt to map the NFS share to the requested drive letter using mount.exe.
|
|
817
|
+
Retries with cleanup if Windows NFS client returns a transient error.
|
|
818
|
+
"""
|
|
819
|
+
import subprocess
|
|
820
|
+
target_str = str(mount_point_abs).rstrip("\\/")
|
|
821
|
+
if re.match(r"^[a-zA-Z]$", target_str):
|
|
822
|
+
target_str += ":"
|
|
823
|
+
|
|
824
|
+
mount_cmd = [
|
|
825
|
+
"C:\\Windows\\System32\\mount.exe",
|
|
826
|
+
"-o", "mtype=hard",
|
|
827
|
+
"-o", "timeout=10",
|
|
828
|
+
"-o", "rsize=32",
|
|
829
|
+
"-o", "wsize=32",
|
|
830
|
+
"-o", "anon",
|
|
831
|
+
"-o", "nolock",
|
|
832
|
+
"\\\\127.0.0.1\\!",
|
|
833
|
+
target_str
|
|
834
|
+
]
|
|
835
|
+
|
|
836
|
+
last_err = ""
|
|
837
|
+
drive_root = str(mount_point_abs).rstrip("\\/") + "\\"
|
|
838
|
+
for attempt in range(1, max_attempts + 1):
|
|
839
|
+
cleanup_stuck_windows_mount_processes()
|
|
840
|
+
|
|
841
|
+
# If daemon died before or between attempts, revive it
|
|
842
|
+
if not is_nfs_daemon_running():
|
|
843
|
+
subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True)
|
|
844
|
+
wait_for_nfs_portmapper("127.0.0.1", 111, 10.0)
|
|
845
|
+
|
|
846
|
+
try:
|
|
847
|
+
res = subprocess.run(mount_cmd, capture_output=True, text=True, timeout=15)
|
|
848
|
+
if res.returncode == 0:
|
|
849
|
+
# Confirm drive is actually mounted and directory listing is populated and queryable
|
|
850
|
+
drive_ready = False
|
|
851
|
+
for _ in range(30):
|
|
852
|
+
try:
|
|
853
|
+
if os.path.exists(drive_root):
|
|
854
|
+
entries = os.listdir(drive_root)
|
|
855
|
+
if len(entries) > 0:
|
|
856
|
+
drive_ready = True
|
|
857
|
+
return True, res.stdout.strip()
|
|
858
|
+
except OSError:
|
|
859
|
+
pass
|
|
860
|
+
time.sleep(0.5)
|
|
861
|
+
if not drive_ready:
|
|
862
|
+
last_err = f"mount.exe reported success but drive {drive_root} was empty or not accessible"
|
|
863
|
+
# Clean up the stale mount connection so next attempt can remount fresh
|
|
864
|
+
subprocess.run(["net", "use", target_str, "/delete", "/y"], capture_output=True)
|
|
865
|
+
else:
|
|
866
|
+
last_err = (res.stderr or res.stdout or "").strip()
|
|
867
|
+
if "85" in last_err or "already in use" in last_err.lower():
|
|
868
|
+
# Release stale network drive letter without unmounting the daemon UNC path
|
|
869
|
+
subprocess.run(["net", "use", target_str, "/delete", "/y"], capture_output=True)
|
|
870
|
+
time.sleep(1.0)
|
|
871
|
+
except subprocess.TimeoutExpired:
|
|
872
|
+
last_err = "mount.exe command timed out"
|
|
873
|
+
cleanup_stuck_windows_mount_processes()
|
|
874
|
+
except Exception as e:
|
|
875
|
+
last_err = str(e)
|
|
876
|
+
|
|
877
|
+
if attempt < max_attempts:
|
|
878
|
+
# Check if daemon needs recovery
|
|
879
|
+
if not is_nfs_daemon_running():
|
|
880
|
+
subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True)
|
|
881
|
+
wait_for_nfs_portmapper("127.0.0.1", 111, 3.0)
|
|
882
|
+
time.sleep(1.0 + (attempt * 0.5))
|
|
883
|
+
|
|
884
|
+
return False, last_err
|
|
885
|
+
|
|
886
|
+
|
|
725
887
|
def enable_windows_nfs_features():
|
|
726
888
|
"""Automatically check and enable Windows Client for NFS features, registry key, and Task Scheduler gateway if needed."""
|
|
727
889
|
import subprocess
|
|
@@ -860,13 +1022,16 @@ def enable_windows_nfs_features():
|
|
|
860
1022
|
|
|
861
1023
|
print("[bold green][OK] Elevated administrative setup completed successfully![/bold green]")
|
|
862
1024
|
|
|
863
|
-
#
|
|
864
|
-
if
|
|
865
|
-
print("")
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
1025
|
+
# Verify if kernel driver is loaded; if active, restart is NOT required and we continue mounting
|
|
1026
|
+
if is_nfs_kernel_driver_active():
|
|
1027
|
+
print("[bold green][OK] Windows NFS kernel redirector is active. Continuing with mount...[/bold green]")
|
|
1028
|
+
return
|
|
1029
|
+
|
|
1030
|
+
print("")
|
|
1031
|
+
print("[bold yellow][WARNING] IMPORTANT: A Windows system restart is required for the new NFS Client features and registry policies to take effect.[/bold yellow]")
|
|
1032
|
+
print("[bold yellow]Please restart your computer, then run 'accli mount start' again to mount your drive passwordlessly![/bold yellow]")
|
|
1033
|
+
print("")
|
|
1034
|
+
raise typer.Exit(0)
|
|
870
1035
|
|
|
871
1036
|
except Exception as e:
|
|
872
1037
|
if isinstance(e, typer.Exit):
|
|
@@ -909,7 +1074,12 @@ def mount_start(
|
|
|
909
1074
|
try:
|
|
910
1075
|
token = get_token()
|
|
911
1076
|
server_url = get_server_url()
|
|
1077
|
+
if not token:
|
|
1078
|
+
print("[bold red]ERROR: No token found. Please run 'accli login' first.[/bold red]")
|
|
1079
|
+
raise typer.Exit(1)
|
|
912
1080
|
except Exception as e:
|
|
1081
|
+
if isinstance(e, typer.Exit):
|
|
1082
|
+
raise e
|
|
913
1083
|
print(f"[bold red]ERROR: Could not retrieve login details. Please run 'accli login' first. Details: {e}[/bold red]")
|
|
914
1084
|
raise typer.Exit(1)
|
|
915
1085
|
|
|
@@ -965,8 +1135,8 @@ def mount_start(
|
|
|
965
1135
|
print(f"[bold red]ERROR: {e}[/bold red]")
|
|
966
1136
|
raise typer.Exit(1)
|
|
967
1137
|
else:
|
|
968
|
-
mount_point_str = str(mount_point)
|
|
969
|
-
if re.match(r"^[a-zA-Z]
|
|
1138
|
+
mount_point_str = str(mount_point).strip()
|
|
1139
|
+
if re.match(r"^[a-zA-Z]:?[\\/]*$", mount_point_str):
|
|
970
1140
|
drive_letter = mount_point_str[0].upper()
|
|
971
1141
|
if os.path.exists(f"{drive_letter}:\\"):
|
|
972
1142
|
print(f"[yellow]Warning: Drive '{drive_letter}:' is already in use.[/yellow]")
|
|
@@ -1106,30 +1276,21 @@ def mount_start(
|
|
|
1106
1276
|
run_res = subprocess.run(["schtasks", "/run", "/tn", "accli-mount-nfs"], capture_output=True, text=True)
|
|
1107
1277
|
if run_res.returncode == 0:
|
|
1108
1278
|
print("[cyan]Waiting for elevated NFS daemon to initialize...[/cyan]")
|
|
1109
|
-
|
|
1279
|
+
if not ensure_nfs_daemon_stable(15.0):
|
|
1280
|
+
print("[bold red][ERROR] Timed out waiting for NFS daemon to bind to 127.0.0.1:111.[/bold red]")
|
|
1281
|
+
raise typer.Exit(1)
|
|
1110
1282
|
|
|
1111
1283
|
# Map the network drive in the CURRENT user session (so it is visible in Explorer)
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
"-o", "mtype=hard",
|
|
1115
|
-
"-o", "timeout=10",
|
|
1116
|
-
"-o", "rsize=32",
|
|
1117
|
-
"-o", "wsize=32",
|
|
1118
|
-
"-o", "anon",
|
|
1119
|
-
"-o", "nolock",
|
|
1120
|
-
"\\\\127.0.0.1\\!",
|
|
1121
|
-
str(mount_point_abs)
|
|
1122
|
-
]
|
|
1123
|
-
mount_res = subprocess.run(mount_cmd, capture_output=True, text=True)
|
|
1124
|
-
if mount_res.returncode == 0:
|
|
1284
|
+
success, err_msg = map_windows_network_drive(mount_point_abs, 5)
|
|
1285
|
+
if success:
|
|
1125
1286
|
start_nfs_watcher(str(mount_point_abs), interval=30)
|
|
1126
1287
|
print(f"[bold green][OK] NFS mount successfully mapped at [white]{mount_point_abs}[/white]![/bold green]")
|
|
1127
1288
|
print("[cyan]Use 'umount' or 'accli mount stop' to unmount the drive.[/cyan]")
|
|
1128
1289
|
return
|
|
1129
1290
|
else:
|
|
1130
1291
|
print(f"[bold red][ERROR] NFS daemon started, but failed to map drive {mount_point_abs} in user session.[/bold red]")
|
|
1131
|
-
if
|
|
1132
|
-
print(f"[red]{
|
|
1292
|
+
if err_msg:
|
|
1293
|
+
print(f"[red]{err_msg}[/red]")
|
|
1133
1294
|
print("[yellow]Tip: Make sure Windows NFS client feature was fully enabled and the PC was restarted.[/yellow]")
|
|
1134
1295
|
raise typer.Exit(1)
|
|
1135
1296
|
else:
|
|
@@ -1208,29 +1369,20 @@ def mount_start(
|
|
|
1208
1369
|
raise typer.Exit(1)
|
|
1209
1370
|
|
|
1210
1371
|
print("[cyan]Waiting for elevated NFS daemon to initialize...[/cyan]")
|
|
1211
|
-
|
|
1372
|
+
if not wait_for_nfs_portmapper("127.0.0.1", 111, 15.0):
|
|
1373
|
+
print("[bold red][ERROR] Timed out waiting for NFS daemon to bind to 127.0.0.1:111.[/bold red]")
|
|
1374
|
+
raise typer.Exit(1)
|
|
1212
1375
|
|
|
1213
1376
|
# Map the network drive in the CURRENT user session (so it is visible in Explorer)
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
"-o", "mtype=hard",
|
|
1217
|
-
"-o", "timeout=10",
|
|
1218
|
-
"-o", "rsize=32",
|
|
1219
|
-
"-o", "wsize=32",
|
|
1220
|
-
"-o", "anon",
|
|
1221
|
-
"-o", "nolock",
|
|
1222
|
-
"\\\\127.0.0.1\\!",
|
|
1223
|
-
str(mount_point_abs)
|
|
1224
|
-
]
|
|
1225
|
-
mount_res = subprocess.run(mount_cmd, capture_output=True, text=True)
|
|
1226
|
-
if mount_res.returncode == 0:
|
|
1377
|
+
success, err_msg = map_windows_network_drive(mount_point_abs, 5)
|
|
1378
|
+
if success:
|
|
1227
1379
|
start_nfs_watcher(str(mount_point_abs), interval=30)
|
|
1228
1380
|
print(f"[bold green][OK] NFS mount successfully mapped at [white]{mount_point_abs}[/white]![/bold green]")
|
|
1229
1381
|
print("[cyan]Use 'umount' or 'accli mount stop' to unmount the drive.[/cyan]")
|
|
1230
1382
|
else:
|
|
1231
1383
|
print(f"[bold red][ERROR] NFS daemon spawned elevated, but failed to map drive {mount_point_abs} in user session.[/bold red]")
|
|
1232
|
-
if
|
|
1233
|
-
print(f"[red]{
|
|
1384
|
+
if err_msg:
|
|
1385
|
+
print(f"[red]{err_msg}[/red]")
|
|
1234
1386
|
raise typer.Exit(1)
|
|
1235
1387
|
except Exception as e:
|
|
1236
1388
|
if isinstance(e, typer.Exit):
|
|
@@ -1349,10 +1501,17 @@ def mount_stop(
|
|
|
1349
1501
|
mount_point_abs = Path.home() / "accelerator" / "mnt" / project_slug
|
|
1350
1502
|
print(f"[cyan]No mount point specified. Selected default directory: [bold white]{mount_point_abs}[/bold white][/cyan]")
|
|
1351
1503
|
else:
|
|
1352
|
-
|
|
1504
|
+
mount_point_str = str(mount_point).strip()
|
|
1505
|
+
if sys_name == "Windows" and re.match(r"^[a-zA-Z]:?[\\/]*$", mount_point_str):
|
|
1506
|
+
mount_point_abs = Path(f"{mount_point_str[0].upper()}:")
|
|
1507
|
+
else:
|
|
1508
|
+
mount_point_abs = mount_point.resolve()
|
|
1353
1509
|
|
|
1354
1510
|
if sys_name == "Windows":
|
|
1355
|
-
|
|
1511
|
+
target_str = str(mount_point_abs).rstrip("\\/")
|
|
1512
|
+
if re.match(r"^[a-zA-Z]$", target_str):
|
|
1513
|
+
target_str += ":"
|
|
1514
|
+
print(f"[bold cyan]Stopping mount at '[white]{target_str}[/white]' on Windows...[/bold cyan]")
|
|
1356
1515
|
import subprocess
|
|
1357
1516
|
|
|
1358
1517
|
# Determine if current user is admin
|
|
@@ -1363,13 +1522,16 @@ def mount_stop(
|
|
|
1363
1522
|
is_admin = False
|
|
1364
1523
|
|
|
1365
1524
|
# 0. Stop background NFS watcher
|
|
1366
|
-
stop_nfs_watcher(
|
|
1525
|
+
stop_nfs_watcher(target_str)
|
|
1367
1526
|
|
|
1368
|
-
# 1. Unmap the network drive in the user session
|
|
1369
|
-
|
|
1527
|
+
# 1. Unmap the network drive in the user session using net use
|
|
1528
|
+
# (Avoids sending RPC UMNT packet via umount.exe which causes kernel NFS redirector to queue stale UMNTs)
|
|
1529
|
+
res = subprocess.run(["net", "use", target_str, "/delete", "/y"], capture_output=True, text=True)
|
|
1370
1530
|
if res.returncode != 0:
|
|
1371
|
-
|
|
1372
|
-
|
|
1531
|
+
res = subprocess.run(["C:\\Windows\\System32\\umount.exe", "-f", target_str], capture_output=True, text=True)
|
|
1532
|
+
subprocess.run(["net", "use", "\\\\127.0.0.1\\!", "/delete", "/y"], capture_output=True)
|
|
1533
|
+
|
|
1534
|
+
cleanup_stuck_windows_mount_processes()
|
|
1373
1535
|
|
|
1374
1536
|
# 2. Terminate the background daemon
|
|
1375
1537
|
has_umount_task = False
|
|
@@ -1390,6 +1552,18 @@ def mount_stop(
|
|
|
1390
1552
|
task_check = subprocess.run(["tasklist", "/FI", "IMAGENAME eq hf-mount-nfs.exe"], capture_output=True, text=True)
|
|
1391
1553
|
if "hf-mount-nfs.exe" not in task_check.stdout:
|
|
1392
1554
|
break
|
|
1555
|
+
cleanup_stuck_windows_mount_processes()
|
|
1556
|
+
for _ in range(10):
|
|
1557
|
+
sched_check = subprocess.run(["schtasks", "/query", "/tn", "accli-mount-nfs"], capture_output=True, text=True)
|
|
1558
|
+
if "Running" not in sched_check.stdout:
|
|
1559
|
+
break
|
|
1560
|
+
time.sleep(0.5)
|
|
1561
|
+
|
|
1562
|
+
# Wait until drive letter is released from Windows namespace
|
|
1563
|
+
for _ in range(10):
|
|
1564
|
+
if not os.path.exists(f"{target_str}\\"):
|
|
1565
|
+
break
|
|
1566
|
+
time.sleep(0.3)
|
|
1393
1567
|
else:
|
|
1394
1568
|
# If admin or task scheduler gateway is not registered, stop/kill directly
|
|
1395
1569
|
if is_admin:
|
|
@@ -1425,6 +1599,7 @@ def mount_stop(
|
|
|
1425
1599
|
|
|
1426
1600
|
if res.returncode == 0 or "successfully" in res.stdout or "successfully" in res.stderr:
|
|
1427
1601
|
print("[bold green][OK] Mount stopped successfully.[/bold green]")
|
|
1602
|
+
print("[cyan]Note: If mounting again, please allow a few seconds for the Windows NFS network cache to clear.[/cyan]")
|
|
1428
1603
|
else:
|
|
1429
1604
|
print("[yellow]Attempted to unmount. Please verify drive status manually using 'umount'.[/yellow]")
|
|
1430
1605
|
if res.stderr:
|
|
@@ -88,7 +88,11 @@ class AccliGuiApp(tk.Tk):
|
|
|
88
88
|
|
|
89
89
|
# Checkboxes
|
|
90
90
|
self.style.configure("TCheckbutton", background="#1e1e1e", foreground="#ffffff")
|
|
91
|
-
self.style.map("TCheckbutton",
|
|
91
|
+
self.style.map("TCheckbutton",
|
|
92
|
+
background=[("active", "#1e1e1e")],
|
|
93
|
+
indicatorbackground=[("selected", "#007acc"), ("!selected", "#2d2d2d")],
|
|
94
|
+
indicatorforeground=[("selected", "#ffffff"), ("!selected", "#2d2d2d")]
|
|
95
|
+
)
|
|
92
96
|
|
|
93
97
|
def create_widgets(self):
|
|
94
98
|
# Top banner
|
|
@@ -122,7 +126,7 @@ class AccliGuiApp(tk.Tk):
|
|
|
122
126
|
cmd = [sys.executable] + args
|
|
123
127
|
else:
|
|
124
128
|
# Script mode
|
|
125
|
-
cmd = [sys.executable, "-m", "accli
|
|
129
|
+
cmd = [sys.executable, "-m", "accli"] + args
|
|
126
130
|
|
|
127
131
|
if elevate and platform.system() != "Windows":
|
|
128
132
|
import shutil
|
|
@@ -162,11 +166,14 @@ class AccliGuiApp(tk.Tk):
|
|
|
162
166
|
process.wait()
|
|
163
167
|
|
|
164
168
|
if on_done:
|
|
165
|
-
|
|
169
|
+
ret_code = process.returncode
|
|
170
|
+
out_text = "".join(output_lines)
|
|
171
|
+
self.after(0, lambda: on_done(ret_code, out_text))
|
|
166
172
|
except Exception as e:
|
|
167
|
-
|
|
173
|
+
err_text = str(e)
|
|
174
|
+
self.append_log(f"ERROR executing command: {err_text}\n")
|
|
168
175
|
if on_done:
|
|
169
|
-
self.after(0, lambda: on_done(-1,
|
|
176
|
+
self.after(0, lambda: on_done(-1, err_text))
|
|
170
177
|
|
|
171
178
|
threading.Thread(target=worker, daemon=True).start()
|
|
172
179
|
|
|
@@ -196,10 +203,26 @@ class AccliGuiApp(tk.Tk):
|
|
|
196
203
|
self.login_btn = ttk.Button(card, text="Authenticate / Login", command=self.action_login)
|
|
197
204
|
self.login_btn.grid(row=0, column=2, padx=10, pady=5)
|
|
198
205
|
|
|
206
|
+
self.debug_mode_val = tk.BooleanVar(value=bool(os.environ.get("ACCLI_DEBUG")))
|
|
207
|
+
self.debug_check = ttk.Checkbutton(
|
|
208
|
+
card,
|
|
209
|
+
text="Debug mode",
|
|
210
|
+
variable=self.debug_mode_val,
|
|
211
|
+
command=self.on_debug_toggle
|
|
212
|
+
)
|
|
213
|
+
self.debug_check.grid(row=1, column=0, columnspan=3, sticky=tk.W, pady=(5, 0))
|
|
214
|
+
|
|
199
215
|
# Connection status details
|
|
200
216
|
self.conn_details_txt = tk.Text(auth_tab, height=12, bg="#2d2d2d", fg="#ffffff", insertbackground="white", font=("Courier New", 10), state=tk.DISABLED)
|
|
201
217
|
self.conn_details_txt.pack(fill=tk.BOTH, expand=True, pady=10)
|
|
202
218
|
|
|
219
|
+
def on_debug_toggle(self):
|
|
220
|
+
if self.debug_mode_val.get():
|
|
221
|
+
os.environ["ACCLI_DEBUG"] = "1"
|
|
222
|
+
else:
|
|
223
|
+
os.environ.pop("ACCLI_DEBUG", None)
|
|
224
|
+
self.refresh_login_status()
|
|
225
|
+
|
|
203
226
|
def refresh_login_status(self):
|
|
204
227
|
from accli.token import get_db_path
|
|
205
228
|
from tinydb import TinyDB
|
|
@@ -229,13 +252,16 @@ class AccliGuiApp(tk.Tk):
|
|
|
229
252
|
self.server_entry.configure(state=tk.NORMAL)
|
|
230
253
|
self.status_bar_val.set("Session Status: Disconnected")
|
|
231
254
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
self.
|
|
255
|
+
details = [
|
|
256
|
+
f"Server URL: {server_url}",
|
|
257
|
+
f"Session Status: {'Connected / Logged In' if logged_in else 'Not Logged In'}",
|
|
258
|
+
f"Token DB Path: {db_path}",
|
|
259
|
+
f"Debug Mode: {'Enabled ($env:ACCLI_DEBUG=\"1\")' if os.environ.get('ACCLI_DEBUG') else 'Disabled'}"
|
|
260
|
+
]
|
|
261
|
+
self.conn_details_txt.configure(state=tk.NORMAL)
|
|
262
|
+
self.conn_details_txt.delete("1.0", tk.END)
|
|
263
|
+
self.conn_details_txt.insert(tk.END, "\n".join(details) + "\n")
|
|
264
|
+
self.conn_details_txt.configure(state=tk.DISABLED)
|
|
239
265
|
|
|
240
266
|
def action_login(self):
|
|
241
267
|
if hasattr(self, "is_logged_in_state") and self.is_logged_in_state:
|
|
@@ -288,7 +314,7 @@ class AccliGuiApp(tk.Tk):
|
|
|
288
314
|
response = requests.post(
|
|
289
315
|
token_endpoint,
|
|
290
316
|
json={"device_authorization_code": auth_code.strip()},
|
|
291
|
-
verify=
|
|
317
|
+
verify=(not bool(os.environ.get("ACCLI_DEBUG")))
|
|
292
318
|
)
|
|
293
319
|
|
|
294
320
|
if response.status_code == 400:
|
|
@@ -306,8 +332,9 @@ class AccliGuiApp(tk.Tk):
|
|
|
306
332
|
self.after(0, lambda: messagebox.showinfo("Success", "Successfully logged in!"))
|
|
307
333
|
self.after(0, self.refresh_login_status)
|
|
308
334
|
except Exception as e:
|
|
309
|
-
|
|
310
|
-
self.
|
|
335
|
+
err_text = str(e)
|
|
336
|
+
self.append_log(f"Authentication Failed: {err_text}\n")
|
|
337
|
+
self.after(0, lambda msg=err_text: messagebox.showerror("Error", f"Authentication failed: {msg}"))
|
|
311
338
|
self.after(0, self.refresh_login_status)
|
|
312
339
|
|
|
313
340
|
threading.Thread(target=perform_auth, daemon=True).start()
|
|
@@ -422,6 +449,9 @@ class AccliGuiApp(tk.Tk):
|
|
|
422
449
|
|
|
423
450
|
mount_btn = ttk.Button(btn_frame, text="Start Mount", command=self.action_mount_start)
|
|
424
451
|
mount_btn.pack(side=tk.LEFT, padx=5)
|
|
452
|
+
|
|
453
|
+
stop_form_btn = ttk.Button(btn_frame, text="Stop Mount", style="Stop.TButton", command=self.action_mount_stop)
|
|
454
|
+
stop_form_btn.pack(side=tk.LEFT, padx=5)
|
|
425
455
|
|
|
426
456
|
# Right Panel (List of mounts)
|
|
427
457
|
list_frame = ttk.LabelFrame(mounts_tab, text="Active Mounts", padding=10)
|
|
@@ -501,20 +531,33 @@ class AccliGuiApp(tk.Tk):
|
|
|
501
531
|
self.run_cli_async(args, on_done=on_done, elevate=True)
|
|
502
532
|
|
|
503
533
|
def action_mount_stop(self):
|
|
504
|
-
selected
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
534
|
+
# 1. Check if user selected an item from the active mounts list
|
|
535
|
+
mount_point = ""
|
|
536
|
+
selected_indices = self.mounts_list.curselection()
|
|
537
|
+
if selected_indices:
|
|
538
|
+
selected = self.mounts_list.get(selected_indices[0])
|
|
539
|
+
parts = selected.split()
|
|
540
|
+
if parts:
|
|
541
|
+
mount_point = parts[0]
|
|
542
|
+
elif self.mounts_list.size() > 0:
|
|
543
|
+
selected = self.mounts_list.get(tk.ACTIVE)
|
|
544
|
+
if selected:
|
|
545
|
+
parts = selected.split()
|
|
546
|
+
if parts:
|
|
547
|
+
mount_point = parts[0]
|
|
548
|
+
|
|
549
|
+
# 2. If nothing selected or list is empty, fallback to the Mount Point / Drive Letter input field
|
|
550
|
+
if not mount_point:
|
|
551
|
+
entered_point = self.mount_point_val.get().strip()
|
|
552
|
+
if entered_point:
|
|
553
|
+
mount_point = entered_point
|
|
554
|
+
|
|
555
|
+
args = ["mount", "stop"]
|
|
556
|
+
if mount_point:
|
|
557
|
+
args.append(mount_point)
|
|
558
|
+
target_desc = mount_point
|
|
559
|
+
else:
|
|
560
|
+
target_desc = "active mount"
|
|
518
561
|
|
|
519
562
|
self.status_bar_val.set("Stopping mount...")
|
|
520
563
|
self.notebook.select(self.log_tab_idx)
|
|
@@ -523,10 +566,10 @@ class AccliGuiApp(tk.Tk):
|
|
|
523
566
|
self.refresh_mounts_list()
|
|
524
567
|
if code == 0:
|
|
525
568
|
self.status_bar_val.set("Mount Stopped")
|
|
526
|
-
messagebox.showinfo("Success", f"Successfully stopped mount
|
|
569
|
+
messagebox.showinfo("Success", f"Successfully stopped mount ({target_desc}).")
|
|
527
570
|
else:
|
|
528
571
|
self.status_bar_val.set("Stop Failed")
|
|
529
|
-
messagebox.showerror("Error", f"Failed to stop mount
|
|
572
|
+
messagebox.showerror("Error", f"Failed to stop mount ({target_desc}). See logs for details.")
|
|
530
573
|
|
|
531
574
|
self.run_cli_async(args, on_done=on_done, elevate=True)
|
|
532
575
|
|
|
@@ -3,6 +3,7 @@ import threading
|
|
|
3
3
|
import time
|
|
4
4
|
import requests
|
|
5
5
|
import typer
|
|
6
|
+
from rich import print
|
|
6
7
|
from tinydb import TinyDB
|
|
7
8
|
|
|
8
9
|
ACCLI_DEBUG = os.environ.get('ACCLI_DEBUG', False)
|
|
@@ -138,6 +139,13 @@ def exchange_refresh_token(project_slug: str) -> tuple[str, str, int]:
|
|
|
138
139
|
_cached_expires_at = expires_at
|
|
139
140
|
|
|
140
141
|
return cas_token, access_token, expires_at
|
|
142
|
+
except requests.exceptions.HTTPError as e:
|
|
143
|
+
if e.response is not None and e.response.status_code in (400, 401, 403):
|
|
144
|
+
print("[bold red]ERROR: Your session has expired or the token is invalid.[/bold red]")
|
|
145
|
+
print("[yellow]Please run 'accli login' to authenticate again.[/yellow]")
|
|
146
|
+
raise typer.Exit(1)
|
|
147
|
+
print(f"[bold red]ERROR: Failed to authenticate/exchange refresh token: {e}[/bold red]")
|
|
148
|
+
raise typer.Exit(1)
|
|
141
149
|
except Exception as e:
|
|
142
150
|
print(f"[bold red]ERROR: Failed to authenticate/exchange refresh token: {e}[/bold red]")
|
|
143
151
|
raise typer.Exit(1)
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test for mounting lifecycle:
|
|
3
|
+
1. Ensure clean initial state (mount stop)
|
|
4
|
+
2. accli mount start <project_slug>
|
|
5
|
+
3. Capture directory structure (ensure not empty) & read any file
|
|
6
|
+
4. accli mount stop
|
|
7
|
+
5. accli mount start <project_slug> again
|
|
8
|
+
6. Capture directory structure (must not be empty) & read any file
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
import platform
|
|
14
|
+
import subprocess
|
|
15
|
+
import unittest
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TestMountLifecycle(unittest.TestCase):
|
|
20
|
+
@classmethod
|
|
21
|
+
def setUpClass(cls):
|
|
22
|
+
cls.project_slug = os.environ.get("ACCLI_TEST_PROJECT", "test")
|
|
23
|
+
cls.mount_drive = os.environ.get("ACCLI_TEST_DRIVE", "W:")
|
|
24
|
+
cls.mount_arg = cls.mount_drive.rstrip("\\/")
|
|
25
|
+
if platform.system() == "Windows" and len(cls.mount_arg) == 1 and cls.mount_arg.isalpha():
|
|
26
|
+
cls.mount_arg += ":"
|
|
27
|
+
cls.mount_path = Path(f"{cls.mount_arg}\\") if platform.system() == "Windows" else Path.home() / "accelerator" / "mnt" / cls.project_slug
|
|
28
|
+
|
|
29
|
+
def run_cli(self, args: list, timeout: int):
|
|
30
|
+
cmd = [sys.executable, "-m", "accli"] + args
|
|
31
|
+
print(f"\n[TEST EXEC] {' '.join(cmd)}")
|
|
32
|
+
result = subprocess.run(
|
|
33
|
+
cmd,
|
|
34
|
+
stdin=subprocess.DEVNULL,
|
|
35
|
+
capture_output=True,
|
|
36
|
+
text=True,
|
|
37
|
+
timeout=timeout
|
|
38
|
+
)
|
|
39
|
+
print(f"[EXIT CODE] {result.returncode}")
|
|
40
|
+
if result.stdout:
|
|
41
|
+
print(f"[STDOUT]\n{result.stdout.strip()}")
|
|
42
|
+
if result.stderr:
|
|
43
|
+
print(f"[STDERR]\n{result.stderr.strip()}")
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
def get_structure(self, target_path: Path):
|
|
47
|
+
"""List files and folders at the mounted location and ensure it is not empty."""
|
|
48
|
+
entries = []
|
|
49
|
+
for _ in range(10):
|
|
50
|
+
try:
|
|
51
|
+
if target_path.exists():
|
|
52
|
+
entries = [p.name for p in target_path.iterdir()]
|
|
53
|
+
if entries:
|
|
54
|
+
break
|
|
55
|
+
except OSError:
|
|
56
|
+
pass
|
|
57
|
+
time.sleep(0.5)
|
|
58
|
+
|
|
59
|
+
self.assertTrue(
|
|
60
|
+
target_path.exists(),
|
|
61
|
+
f"Mount path '{target_path}' does not exist or drive is not mapped."
|
|
62
|
+
)
|
|
63
|
+
print(f"[MOUNT STRUCTURE at {target_path}]: {entries}")
|
|
64
|
+
return entries
|
|
65
|
+
|
|
66
|
+
def read_any_file(self, target_path: Path):
|
|
67
|
+
"""Locate any readable file under the mounted project path and read its content."""
|
|
68
|
+
search_dirs = [target_path / self.project_slug, target_path]
|
|
69
|
+
for sdir in search_dirs:
|
|
70
|
+
if not sdir.exists():
|
|
71
|
+
continue
|
|
72
|
+
for root, dirs, files in os.walk(sdir):
|
|
73
|
+
for file_name in files:
|
|
74
|
+
candidate = Path(root) / file_name
|
|
75
|
+
try:
|
|
76
|
+
print(f"[TEST READ FILE] Attempting to read: {candidate}")
|
|
77
|
+
with open(candidate, "rb") as fp:
|
|
78
|
+
data = fp.read(1024)
|
|
79
|
+
print(f"[TEST READ FILE] Successfully read {len(data)} bytes from '{candidate.name}'.")
|
|
80
|
+
return candidate, len(data)
|
|
81
|
+
except Exception as e:
|
|
82
|
+
print(f"[TEST READ FILE] Skipping unreadable candidate {candidate}: {e}")
|
|
83
|
+
|
|
84
|
+
self.fail(f"Could not read any file under mounted path '{target_path}'.")
|
|
85
|
+
|
|
86
|
+
def tearDown(self):
|
|
87
|
+
try:
|
|
88
|
+
self.run_cli(["mount", "stop", self.mount_arg], 30)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
def test_mount_start_stop_start_lifecycle(self):
|
|
93
|
+
# Step 0: Ensure clean initial state
|
|
94
|
+
print("\n--- Step 0: Ensure clean initial state ---")
|
|
95
|
+
self.run_cli(["mount", "stop", self.mount_arg], 30)
|
|
96
|
+
time.sleep(2)
|
|
97
|
+
|
|
98
|
+
# Step 1: First mount start
|
|
99
|
+
print(f"\n--- Step 1: Starting mount for '{self.project_slug}' at '{self.mount_arg}' ---")
|
|
100
|
+
start1 = self.run_cli(["mount", "start", self.project_slug, self.mount_arg], 60)
|
|
101
|
+
self.assertEqual(start1.returncode, 0, f"First mount start failed: {start1.stderr or start1.stdout}")
|
|
102
|
+
|
|
103
|
+
# Step 2: Check structure is not empty and try to read any file
|
|
104
|
+
print("\n--- Step 2: Checking structure & reading file after 1st mount ---")
|
|
105
|
+
structure1 = self.get_structure(self.mount_path)
|
|
106
|
+
self.assertGreater(
|
|
107
|
+
len(structure1), 0,
|
|
108
|
+
f"Expected structure to be not empty after 1st mount, got: {structure1}"
|
|
109
|
+
)
|
|
110
|
+
file1, bytes_read1 = self.read_any_file(self.mount_path)
|
|
111
|
+
self.assertIsNotNone(file1)
|
|
112
|
+
|
|
113
|
+
# Step 3: Mount stop (clean)
|
|
114
|
+
print(f"\n--- Step 3: Stopping mount at '{self.mount_arg}' ---")
|
|
115
|
+
stop = self.run_cli(["mount", "stop", self.mount_arg], 30)
|
|
116
|
+
self.assertEqual(stop.returncode, 0, f"Mount stop failed: {stop.stderr or stop.stdout}")
|
|
117
|
+
time.sleep(3)
|
|
118
|
+
|
|
119
|
+
# Step 4: Second mount start
|
|
120
|
+
print(f"\n--- Step 4: Starting mount for '{self.project_slug}' at '{self.mount_arg}' (2nd time) ---")
|
|
121
|
+
start2 = self.run_cli(["mount", "start", self.project_slug, self.mount_arg], 120)
|
|
122
|
+
self.assertEqual(start2.returncode, 0, f"Second mount start failed: {start2.stderr or start2.stdout}")
|
|
123
|
+
|
|
124
|
+
# Step 5: Check structure is not empty and try to read any file after clean & remount
|
|
125
|
+
print("\n--- Step 5: Checking structure & reading file after 2nd mount ---")
|
|
126
|
+
structure2 = self.get_structure(self.mount_path)
|
|
127
|
+
self.assertGreater(
|
|
128
|
+
len(structure2), 0,
|
|
129
|
+
f"Expected structure to be not empty after 2nd mount, got: {structure2}"
|
|
130
|
+
)
|
|
131
|
+
file2, bytes_read2 = self.read_any_file(self.mount_path)
|
|
132
|
+
self.assertIsNotNone(file2)
|
|
133
|
+
|
|
134
|
+
print("\n[SUCCESS] Mount start -> read -> stop -> start -> read lifecycle test completed successfully!")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|