diff --git a/pathwaysutils/experimental/remote_ide/remote_ide.py b/pathwaysutils/experimental/remote_ide/remote_ide.py new file mode 100644 index 0000000..671aafa --- /dev/null +++ b/pathwaysutils/experimental/remote_ide/remote_ide.py @@ -0,0 +1,458 @@ +"""Launch Jupyter/VSCode in GKE pods (supporting pathways, non-pathways, and SPS).""" + +import argparse +import multiprocessing +import os +import re +import select +import signal +import socket +import sys +import threading +import time +from kubernetes import client +from kubernetes import config +from kubernetes import stream + +CURRENT_PATH = os.path.abspath(os.path.dirname(__file__)) + + +def get_args(): + """Parses command-line arguments.""" + parser = argparse.ArgumentParser( + description="Launch Jupyter/VSCode (File-based Scripts)." + ) + default_user = os.getenv("USER", "user") + default_pattern = os.getenv("POD_PATTERN", default_user) + + parser.add_argument( + "-m", + "--mode", + default=os.getenv("MODE", "jupyter"), + choices=["jupyter", "vscode"], + help="Mode to launch", + ) + parser.add_argument( + "-w", + "--workload", + default=default_pattern, + help="Regex pattern for pod name", + ) + parser.add_argument( + "-P", + "--port", + type=int, + default=int(os.getenv("PORT", "8888")), + help="Port to forward", + ) + parser.add_argument( + "-b", + "--bucket", + default=os.getenv("GCS_BUCKET", ""), + help="GCS bucket name for history sync (optional)", + ) + parser.add_argument( + "-c", + "--check-active-session", + action="store_true", + help=( + "Check if session exists. If running, skip setup and just tunnel." + ), + ) + parser.add_argument( + "--non-pathways", + action="store_true", + help=( + "if non-pathways, use workload name directly as search pattern" + " instead of pathways-head pattern" + ), + ) + parser.add_argument( + "-s", + "--sps", + action="store_true", + help=( + "if sps, launch in Shared Pathways Service (SPS) mode, using" + " container name pathways-remote-env" + ), + ) + parser.add_argument( + "-y", + "--yaml", + default="", + help=( + "Path to YAML file to deploy if no running pod is found (SPS mode" + " only)." + ), + ) + parser.add_argument( + "-i", + "--sps-image", + default="codercom/code-server:latest", + help="Container image for SPS pod deployment (SPS mode only).", + ) + parser.add_argument( + "--instance-type", + default="c4-standard-192", + help="Node instance type selector (SPS mode only).", + ) + return parser.parse_args() + + +def is_port_active(pod_name, port, container_name): + """Executes a small python snippet inside the pod to check if the port is bound. + + Args: + pod_name: Name of the pod. + port: Bound port to verify. + container_name: Target container runtime name. + + Returns: + True if OPEN, False if CLOSED. + """ + load_k8s_config() + v1 = client.CoreV1Api() + + # Python one-liner to check a port (works on almost all images with python3) + check_cmd = [ + "python3", + "-c", + ( + "import socket; s = socket.socket(socket.AF_INET," + f" socket.SOCK_STREAM); res = s.connect_ex(('127.0.0.1', {port}));" + " print('OPEN' if res == 0 else 'CLOSED'); s.close()" + ), + ] + try: + resp = stream.stream( + v1.connect_get_namespaced_pod_exec, + pod_name, + "default", + command=check_cmd, + container=container_name, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _preload_content=True, # Wait for output + ) + return "OPEN" in resp + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Warning: Could not check port status ({e}). Assuming closed.") + return False + + +def load_script(filename, port, bucket, workload): + """Reads a bash script from disk and injects variables.""" + try: + with open(filename, "r") as f: + script_content = f.read() + + # Replace the placeholders with actual values + script_content = script_content.replace("{PORT}", str(port)) + script_content = script_content.replace("{BUCKET}", bucket if bucket else "") + script_content = script_content.replace("{WORKLOAD}", workload) + + # Return command formatted for 'bash -c' + return ["/bin/bash", "-c", script_content] + except FileNotFoundError: + print(f"Error: Could not find script file '{filename}'") + sys.exit(1) + + +def load_k8s_config(): + """Loads Kubernetes configuration.""" + try: + config.load_kube_config() + except Exception: # pylint: disable=broad-exception-caught + config.load_incluster_config() + + +def find_pod(pattern, exit_on_fail=True): + """Finds a running pod in the default namespace matching the pattern regex.""" + load_k8s_config() + v1 = client.CoreV1Api() + pods = v1.list_namespaced_pod("default") + regex = re.compile(pattern) + + for pod in pods.items: + if regex.search(pod.metadata.name) and pod.status.phase == "Running": + return pod.metadata.name + + if exit_on_fail: + print(f"No running pod found matching: {pattern}") + sys.exit(1) + return None + + +def deploy_yaml(yaml_path, workload, workload_name, image, port, instance_type): + """Reads a YAML file, substitutes placeholders, and deploys it.""" + try: + with open(yaml_path, "r") as f: + content = f.read() + except OSError as e: + print(f"Error: Could not read YAML file '{yaml_path}': {e}") + sys.exit(1) + + # Perform placeholders substitution + content = content.replace("{WORKLOAD}", workload) + content = content.replace("${WORKLOAD}", workload) + content = content.replace("{WORKLOAD_NAME}", workload_name) + content = content.replace("${WORKLOAD_NAME}", workload_name) + content = content.replace("{IMAGE}", image) + content = content.replace("${IMAGE}", image) + content = content.replace("{PORT}", str(port)) + content = content.replace("${PORT}", str(port)) + content = content.replace("{INSTANCE_TYPE}", instance_type) + content = content.replace("${INSTANCE_TYPE}", instance_type) + + # Deploy using kubectl + import subprocess # pylint: disable=g-import-not-at-top + try: + res = subprocess.run( + ["kubectl", "apply", "-f", "-"], + input=content, + text=True, + capture_output=True, + check=True, + ) + print(res.stdout) + except subprocess.CalledProcessError as e: + print(f"Error deploying YAML file: {e.stderr}") + sys.exit(1) + + +class PortForwarderServer: + """Implements a port forwarder from local to the k8s pod.""" + + def __init__(self, pod_name, local_port, remote_port, namespace="default"): + self.pod_name = pod_name + self.local_port = local_port + self.remote_port = remote_port + self.namespace = namespace + self.running = True + self.v1 = None + + def run(self): + """Starts the port forwarding server loop.""" + load_k8s_config() + self.v1 = client.CoreV1Api() + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + + try: + server_socket.bind(("127.0.0.1", self.local_port)) + server_socket.listen(5) + sys.stdout.write( + f"[Tunnel] Forwarding 127.0.0.1:{self.local_port} ->" + f" {self.pod_name}\n" + ) + sys.stdout.flush() + except OSError as e: + sys.stderr.write(f"[Tunnel Error] Cannot bind port {self.local_port}: {e}\n") + return + while self.running: + try: + local_conn, _ = server_socket.accept() + t = threading.Thread(target=self._handle_client, args=(local_conn,)) + t.daemon = True + t.start() + except KeyboardInterrupt: + break + except Exception: # pylint: disable=broad-exception-caught + pass + + def _handle_client(self, local_conn): + """Handles an incoming client connection by bridging to kubernetes socket.""" + k8s_socket = None + v1 = self.v1 + if v1 is None: + load_k8s_config() + v1 = client.CoreV1Api() + self.v1 = v1 + try: + pf_stream = stream.portforward( + v1.connect_get_namespaced_pod_portforward, + self.pod_name, + self.namespace, + ports=str(self.remote_port), + ) + k8s_socket = pf_stream.socket(self.remote_port) + self._bridge_sockets(local_conn, k8s_socket) + except Exception: # pylint: disable=broad-exception-caught + pass + finally: + local_conn.close() + if k8s_socket: + k8s_socket.close() + + def _bridge_sockets(self, sock1, sock2): + """Bridges data transfer between two sockets.""" + sockets = [sock1, sock2] + buffer_size = 32768 + while True: + r, _, _ = select.select(sockets, [], []) + if sock1 in r: + data = sock1.recv(buffer_size) + if not data: + break + sock2.sendall(data) + if sock2 in r: + data = sock2.recv(buffer_size) + if not data: + break + sock1.sendall(data) + + +def run_tunnel_process(pod_name, local_port, remote_port): + """Starts a client-facing port-forwarding server in a subprocess.""" + signal.signal(signal.SIGINT, signal.SIG_IGN) + server = PortForwarderServer(pod_name, local_port, remote_port) + server.run() + + +def main(): + args = get_args() + + # 1. Determine Container Name and Search Pattern + container_name = "pathways-remote-env" if args.sps else "jax-tpu" + + search_pattern = args.workload + if not args.sps: + if args.non_pathways: + search_pattern = args.workload + else: + search_pattern = f"{args.workload}-pathways-head" + + # 2. Find or Deploy Pod + if args.sps: + pod_name = find_pod(search_pattern, exit_on_fail=False) + if pod_name: + print(f"Found running SPS pod: {pod_name}") + else: + yaml_path = args.yaml + if not yaml_path: + yaml_path = os.path.join( + CURRENT_PATH, + "..", + "shared_pathways_service", + "yamls", + "sps-pod.yaml", + ) + print(f"No running SPS pod found. Using default template: {yaml_path}") + else: + print(f"No running SPS pod found. Deploying using YAML: {yaml_path}") + deploy_yaml( + yaml_path, + args.workload, + search_pattern, + args.sps_image, + args.port, + args.instance_type, + ) + + # Poll until the pod is running + print("Waiting for SPS pod to be running...") + timeout = 90 + start_time = time.time() + while time.time() - start_time < timeout: + pod_name = find_pod(search_pattern, exit_on_fail=False) + if pod_name: + break + time.sleep(2) + + if not pod_name: + print( + "Timeout waiting for SPS pod matching" + f" {search_pattern} to be running." + ) + sys.exit(1) + print(f"SPS pod is running: {pod_name}") + else: + pod_name = find_pod(search_pattern, exit_on_fail=True) + print(f"Target Pod: {pod_name}") + + # 3. Start Tunnel + tunnel_proc = multiprocessing.Process( + target=run_tunnel_process, args=(pod_name, args.port, args.port) + ) + tunnel_proc.start() + time.sleep(1) + + skip_setup = False + if args.check_active_session: + print( + f"Checking for existing {args.mode} session on port {args.port}..." + ) + if is_port_active(pod_name, args.port, container_name): + print("Active session detected! Skipping setup script.") + skip_setup = True + else: + print("No active session found. Proceeding with installation.") + + # 4. Execute Logic + try: + if skip_setup: + # If skipping setup, we just need to keep the script alive to keep the tunnel open + print( + "Session ready (Port Forwarding Only). Access at" + f" http://127.0.0.1:{args.port}" + ) + print("Press Ctrl+C to stop.") + while True: + time.sleep(1) + else: + # Standard Path: Load and Run Script + cmd = [] + if args.mode == "jupyter": + print("Loading 'jupyter_setup.sh'...") + jupyter_setup_cmd = os.path.join( + CURRENT_PATH, "scripts", "jupyter_setup.sh" + ) + cmd = load_script( + jupyter_setup_cmd, args.port, args.bucket, args.workload + ) + elif args.mode == "vscode": + print("Loading 'vscode_setup.sh'...") + vscode_setup_cmd = os.path.join( + CURRENT_PATH, "scripts", "vscode_setup.sh" + ) + cmd = load_script( + vscode_setup_cmd, args.port, args.bucket, args.workload + ) + load_k8s_config() + v1 = client.CoreV1Api() + print(f"Session ready. Access at http://127.0.0.1:{args.port}") + print("Press Ctrl+C to stop.") + resp = stream.stream( + v1.connect_get_namespaced_pod_exec, + pod_name, + "default", + command=cmd, + container=container_name, + stderr=True, + stdin=False, + stdout=True, + tty=False, + _preload_content=False, + ) + while resp.is_open(): + resp.update(timeout=1) + if resp.peek_stdout(): + print(resp.read_stdout(), end="") + if resp.peek_stderr(): + print(resp.read_stderr(), end="") + except KeyboardInterrupt: + print("\nStopping session...") + except Exception as e: # pylint: disable=broad-exception-caught + print(f"Execution Error: {e}") + finally: + if tunnel_proc.is_alive(): + tunnel_proc.terminate() + tunnel_proc.join() + print("Tunnel closed.") + + +if __name__ == "__main__": + multiprocessing.freeze_support() + main() diff --git a/pathwaysutils/experimental/remote_ide/remote_ide_test.py b/pathwaysutils/experimental/remote_ide/remote_ide_test.py new file mode 100644 index 0000000..b303781 --- /dev/null +++ b/pathwaysutils/experimental/remote_ide/remote_ide_test.py @@ -0,0 +1,284 @@ +"""Tests for remote_ide.""" + +import argparse +import os +from unittest import mock + +from absl.testing import absltest +from pathwaysutils.experimental.remote_ide import remote_ide + + +class RemoteIdeTest(absltest.TestCase): + + def setUp(self): + super().setUp() + # Mock methods that interact with Kubernetes/subprocesses + self.mock_find_pod = self.enter_context( + mock.patch.object(remote_ide, "find_pod", return_value="dummy-pod") + ) + self.mock_process = self.enter_context( + mock.patch("multiprocessing.Process") + ) + self.mock_load_script = self.enter_context( + mock.patch.object( + remote_ide, "load_script", return_value=["bash", "-c", "echo"] + ) + ) + self.mock_load_k8s_config = self.enter_context( + mock.patch.object(remote_ide, "load_k8s_config") + ) + self.mock_core_v1_api = self.enter_context( + mock.patch.object(remote_ide.client, "CoreV1Api") + ) + self.mock_stream = self.enter_context( + mock.patch( + "pathwaysutils.experimental.remote_ide.remote_ide.stream.stream" + ) + ) + # Mock stream response to exit loop immediately + self.mock_stream_instance = mock.Mock() + self.mock_stream_instance.is_open.return_value = False + self.mock_stream.return_value = self.mock_stream_instance + + @mock.patch.dict(os.environ, {"USER": "testuser"}) + def test_main_default_pathways(self): + mock_args = argparse.Namespace( + mode="jupyter", + workload="testuser", + port=8888, + bucket="", + check_active_session=False, + non_pathways=False, + sps=False, + yaml="", + sps_image="my-image", + instance_type="c4-standard-192", + ) + with mock.patch.object(remote_ide, "get_args", return_value=mock_args): + remote_ide.main() + + self.mock_find_pod.assert_called_once_with( + "testuser-pathways-head", exit_on_fail=True + ) + self.mock_stream.assert_called_once() + kwargs = self.mock_stream.call_args[1] + self.assertEqual(kwargs["container"], "jax-tpu") + + @mock.patch.dict(os.environ, {"USER": "testuser"}) + def test_main_sps_default_workload(self): + mock_args = argparse.Namespace( + mode="jupyter", + workload="testuser", + port=8888, + bucket="", + check_active_session=False, + non_pathways=False, + sps=True, + yaml="", + sps_image="my-image", + instance_type="c4-standard-192", + ) + with mock.patch.object(remote_ide, "get_args", return_value=mock_args): + remote_ide.main() + + self.mock_find_pod.assert_called_once_with( + "testuser", exit_on_fail=False + ) + self.mock_stream.assert_called_once() + kwargs = self.mock_stream.call_args[1] + self.assertEqual(kwargs["container"], "pathways-remote-env") + + @mock.patch.dict(os.environ, {"USER": "testuser"}) + def test_main_sps_custom_workload(self): + mock_args = argparse.Namespace( + mode="jupyter", + workload="my-custom-proxy", + port=8888, + bucket="", + check_active_session=False, + non_pathways=False, + sps=True, + yaml="", + sps_image="my-image", + instance_type="c4-standard-192", + ) + with mock.patch.object(remote_ide, "get_args", return_value=mock_args): + remote_ide.main() + + self.mock_find_pod.assert_called_once_with( + "my-custom-proxy", exit_on_fail=False + ) + + @mock.patch.dict(os.environ, {"USER": "testuser"}) + def test_main_non_pathways(self): + mock_args = argparse.Namespace( + mode="jupyter", + workload="my-pod", + port=8888, + bucket="", + check_active_session=False, + non_pathways=True, + sps=False, + yaml="", + sps_image="my-image", + instance_type="c4-standard-192", + ) + with mock.patch.object(remote_ide, "get_args", return_value=mock_args): + remote_ide.main() + + self.mock_find_pod.assert_called_once_with("my-pod", exit_on_fail=True) + self.mock_stream.assert_called_once() + kwargs = self.mock_stream.call_args[1] + self.assertEqual(kwargs["container"], "jax-tpu") + + @mock.patch.dict(os.environ, {"USER": "testuser"}) + def test_main_active_session_skip_setup(self): + mock_args = argparse.Namespace( + mode="vscode", + workload="testuser", + port=8888, + bucket="", + check_active_session=True, + non_pathways=False, + sps=False, + yaml="", + sps_image="my-image", + instance_type="c4-standard-192", + ) + mock_is_port_active = self.enter_context( + mock.patch.object(remote_ide, "is_port_active", return_value=True) + ) + + sleep_calls = [] + def mock_sleep_fn(seconds): + sleep_calls.append(seconds) + if len(sleep_calls) > 1: + raise KeyboardInterrupt() + + # Use a dummy loop exit side effect to avoid infinite sleep + with mock.patch.object( + remote_ide, "get_args", return_value=mock_args + ), mock.patch("time.sleep", side_effect=mock_sleep_fn): + remote_ide.main() + + mock_is_port_active.assert_called_once_with( + "dummy-pod", 8888, "jax-tpu" + ) + # Since skip_setup is True, load_script and stream should NOT be called + self.mock_load_script.assert_not_called() + self.mock_stream.assert_not_called() + + @mock.patch.dict(os.environ, {"USER": "testuser"}) + def test_main_sps_pod_deployment(self): + mock_args = argparse.Namespace( + mode="jupyter", + workload="testuser", + port=8888, + bucket="", + check_active_session=False, + non_pathways=False, + sps=True, + yaml="my-sps-pod-template.yaml", + sps_image="my-container-image", + instance_type="c4-standard-192", + ) + mock_deploy_yaml = self.enter_context( + mock.patch.object(remote_ide, "deploy_yaml") + ) + mock_sleep = self.enter_context(mock.patch("time.sleep")) + + # We want find_pod to return None on direct check, None on the first poll check, + # and then "my-deployed-sps-pod" on the second poll check. + self.mock_find_pod.side_effect = [None, None, "my-deployed-sps-pod"] + + with mock.patch.object(remote_ide, "get_args", return_value=mock_args): + remote_ide.main() + + mock_deploy_yaml.assert_called_once_with( + "my-sps-pod-template.yaml", + "testuser", + "testuser", + "my-container-image", + 8888, + "c4-standard-192", + ) + self.mock_find_pod.assert_has_calls([ + mock.call("testuser", exit_on_fail=False), + mock.call("testuser", exit_on_fail=False), + ]) + mock_sleep.assert_has_calls([mock.call(2), mock.call(1)]) + + @mock.patch.dict(os.environ, {"USER": "testuser"}) + def test_main_sps_default_yaml_deployment(self): + mock_args = argparse.Namespace( + mode="jupyter", + workload="testuser", + port=8888, + bucket="", + check_active_session=False, + non_pathways=False, + sps=True, + yaml="", + sps_image="my-default-image", + instance_type="cpu-test-type", + ) + mock_deploy_yaml = self.enter_context( + mock.patch.object(remote_ide, "deploy_yaml") + ) + self.enter_context(mock.patch("time.sleep")) + + self.mock_find_pod.side_effect = [None, "my-default-sps-pod"] + + with mock.patch.object(remote_ide, "get_args", return_value=mock_args): + remote_ide.main() + + expected_default_yaml_path = os.path.join( + remote_ide.CURRENT_PATH, + "..", + "shared_pathways_service", + "yamls", + "sps-pod.yaml", + ) + mock_deploy_yaml.assert_called_once_with( + expected_default_yaml_path, + "testuser", + "testuser", + "my-default-image", + 8888, + "cpu-test-type", + ) + + def test_deploy_yaml_success(self): + mock_open_func = mock.mock_open( + read_data=( + "workload: {WORKLOAD}, name: {WORKLOAD_NAME}, image: {IMAGE}, " + "port: {PORT}, instance_type: {INSTANCE_TYPE}" + ) + ) + mock_run = self.enter_context(mock.patch("subprocess.run")) + mock_run.return_value = mock.Mock(stdout="kubectl output") + + with mock.patch("builtins.open", mock_open_func): + remote_ide.deploy_yaml( + "dummy.yaml", + "my-workload", + "my-workload", + "my-image", + 8888, + "c4-standard-192", + ) + + mock_open_func.assert_called_once_with("dummy.yaml", "r") + mock_run.assert_called_once() + kwargs = mock_run.call_args[1] + self.assertEqual( + kwargs["input"], + ( + "workload: my-workload, name: my-workload, image:" + " my-image, port: 8888, instance_type: c4-standard-192" + ), + ) + + +if __name__ == "__main__": + absltest.main() diff --git a/pathwaysutils/experimental/remote_ide/scripts/jupyter_setup.sh b/pathwaysutils/experimental/remote_ide/scripts/jupyter_setup.sh new file mode 100644 index 0000000..5ce12ad --- /dev/null +++ b/pathwaysutils/experimental/remote_ide/scripts/jupyter_setup.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# jupyter_setup.sh + +# Update and install dependencies +sudo apt update > /dev/null +pip3 install jupyterlab > /dev/null + +# Launch Jupyter Lab +# We use {PORT} as a placeholder to be replaced by Python +echo "Starting Jupyter Lab on port {PORT}..." +jupyter lab --allow-root --ip=127.0.0.1 --port={PORT} diff --git a/pathwaysutils/experimental/remote_ide/scripts/vscode_setup.sh b/pathwaysutils/experimental/remote_ide/scripts/vscode_setup.sh new file mode 100644 index 0000000..600d64c --- /dev/null +++ b/pathwaysutils/experimental/remote_ide/scripts/vscode_setup.sh @@ -0,0 +1,91 @@ +#!/bin/bash +if [ -w /proc/1/fd/1 ]; then + exec > >(tee /proc/1/fd/1) 2> >(tee /proc/1/fd/2 >&2) +fi +# Variables +PORT="{PORT}" +BUCKET="{BUCKET}" +WORKLOAD="{WORKLOAD}" +# Paths +VSCODE_USER_DIR="$HOME/.local/share/code-server/User" +VSCODE_EXT_DIR="$HOME/.local/share/code-server/extensions" +SHELL_HIST_FILE="$HOME/.bash_history" +# [NEW] The directory where your code lives (Current Folder) +WORKSPACE_DIR=$(pwd) +# 0. SHELL CONFIGURATION +if ! grep -q "history -a" "$HOME/.bashrc"; then + echo "export PROMPT_COMMAND='history -a'" >> "$HOME/.bashrc" +fi +if ! grep -q "/proc/1/fd/1" "$HOME/.bashrc"; then + cat <<'EOF' >> "$HOME/.bashrc" +if [ -w /proc/1/fd/1 ]; then + exec > >(tee /proc/1/fd/1) 2> >(tee /proc/1/fd/2 >&2) +fi +EOF +fi +# 1. RESTORE +if [ ! -z "$BUCKET" ] && command -v gcloud &> /dev/null; then + echo "[Restore] Checking gs://$BUCKET/$WORKLOAD/..." + + mkdir -p "$VSCODE_USER_DIR" + mkdir -p "$VSCODE_EXT_DIR" + # A. Restore Project Files (Code, Notebooks) -- [NEW SECTION] + if gcloud storage ls "gs://$BUCKET/$WORKLOAD/workspace/**" >/dev/null 2>&1; then + echo "[Restore] Downloading Project Files..." + # Exclude hidden folders like .git or .local to save time/errors + gcloud storage rsync "gs://$BUCKET/$WORKLOAD/workspace" "$WORKSPACE_DIR" --recursive --delete-unmatched-destination-objects --exclude="\..*" + fi + # B. Restore Shell History + if gcloud storage ls "gs://$BUCKET/$WORKLOAD/vscode/shell_history" >/dev/null 2>&1; then + gcloud storage cp "gs://$BUCKET/$WORKLOAD/vscode/shell_history" "$SHELL_HIST_FILE" + fi + # C. Restore User Data + if gcloud storage ls "gs://$BUCKET/$WORKLOAD/vscode/User/**" >/dev/null 2>&1; then + gcloud storage rsync "gs://$BUCKET/$WORKLOAD/vscode/User" "$VSCODE_USER_DIR" --recursive --delete-unmatched-destination-objects + fi + # D. Restore Extensions + if gcloud storage ls "gs://$BUCKET/$WORKLOAD/vscode/extensions/**" >/dev/null 2>&1; then + gcloud storage rsync "gs://$BUCKET/$WORKLOAD/vscode/extensions" "$VSCODE_EXT_DIR" --recursive --delete-unmatched-destination-objects + fi +fi +# 2. SYNC SERVICE +if [ ! -z "$BUCKET" ] && command -v gcloud &> /dev/null; then + echo "[Sync] Starting background sync service..." + cat < /tmp/sync_loop.sh +#!/bin/bash +if [ -w /proc/1/fd/1 ]; then + exec > >(tee /proc/1/fd/1) 2> >(tee /proc/1/fd/2 >&2) +fi +while true; do + # 1. Sync Project Files (Code) -- [NEW SECTION] + # We exclude hidden files (starts with .) and __pycache__ to keep it clean + if [ -d "$WORKSPACE_DIR" ]; then + gcloud storage rsync "$WORKSPACE_DIR" "gs://$BUCKET/$WORKLOAD/workspace" --recursive --delete-unmatched-destination-objects + fi + # 2. Sync Shell History + if [ -f "$SHELL_HIST_FILE" ]; then + gcloud storage cp "$SHELL_HIST_FILE" "gs://$BUCKET/$WORKLOAD/vscode/shell_history" >/dev/null 2>&1 + fi + + # 3. Sync User Data + if [ -d "$VSCODE_USER_DIR" ]; then + gcloud storage rsync "$VSCODE_USER_DIR" "gs://$BUCKET/$WORKLOAD/vscode/User" --recursive --delete-unmatched-destination-objects >/dev/null 2>&1 + fi + # 4. Sync Extensions + if [ -d "$VSCODE_EXT_DIR" ]; then + gcloud storage rsync "$VSCODE_EXT_DIR" "gs://$BUCKET/$WORKLOAD/vscode/extensions" --recursive --delete-unmatched-destination-objects >/dev/null 2>&1 + fi + sleep 30 +done +EOF + chmod +x /tmp/sync_loop.sh + /tmp/sync_loop.sh & +fi +# 3. START VS CODE +if ! command -v code-server &> /dev/null; then + echo "Installing code-server..." + sudo apt update > /dev/null && sudo apt install -y curl > /dev/null + curl -fsSL https://code-server.dev/install.sh | sh > /dev/null +fi +echo "Starting VS Code Server on port $PORT..." +code-server --bind-addr 127.0.0.1:{PORT} --auth none --disable-telemetry --disable-update-check . diff --git a/pathwaysutils/experimental/shared_pathways_service/yamls/sps-pod.yaml b/pathwaysutils/experimental/shared_pathways_service/yamls/sps-pod.yaml new file mode 100644 index 0000000..63a3640 --- /dev/null +++ b/pathwaysutils/experimental/shared_pathways_service/yamls/sps-pod.yaml @@ -0,0 +1,29 @@ +apiVersion: v1 +kind: Pod +metadata: + name: {WORKLOAD_NAME} + labels: + app: {WORKLOAD_NAME} +spec: + automountServiceAccountToken: false + containers: + - name: pathways-remote-env + image: {IMAGE} + imagePullPolicy: Always + ports: + - containerPort: {PORT} + env: + - name: PORT + value: "{PORT}" + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + readOnlyRootFilesystem: false + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + seccompProfile: + type: RuntimeDefault + nodeSelector: + node.kubernetes.io/instance-type: "{INSTANCE_TYPE}"