diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acc063c5..582968fa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -977,6 +977,31 @@ jobs: SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} run: | cloudos interactive-session status --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID + interactive_session_pause: + needs: interactive_session_create + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: setup.py + - name: Install dependencies + run: | + pip install -e . + - name: Pause interactive session + env: + CLOUDOS_TOKEN: ${{ secrets.CLOUDOS_TOKEN_ADAPT }} + CLOUDOS_WORKSPACE_ID: ${{ secrets.CLOUDOS_WORKSPACE_ID_ADAPT }} + CLOUDOS_URL: "https://cloudos.lifebit.ai" + SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} + run: | + cloudos interactive-session pause --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID --yes diff --git a/.github/workflows/ci_az.yml b/.github/workflows/ci_az.yml index 35e187db..453c7e68 100644 --- a/.github/workflows/ci_az.yml +++ b/.github/workflows/ci_az.yml @@ -765,4 +765,30 @@ jobs: SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} run: | cloudos interactive-session status --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID + interactive_session_pause: + needs: interactive_session_create + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: setup.py + - name: Install dependencies + run: | + pip install -e . + - name: Pause interactive session + env: + CLOUDOS_TOKEN: ${{ secrets.CLOUDOS_TOKEN_AZURE }} + CLOUDOS_WORKSPACE_ID: ${{ secrets.CLOUDOS_WORKSPACE_ID_AZURE }} + CLOUDOS_URL: "https://dev.sdlc.lifebit.ai" + SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} + run: | + cloudos interactive-session pause --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID --yes + diff --git a/.github/workflows/ci_dev.yml b/.github/workflows/ci_dev.yml index 08aecdd5..b7b8757d 100644 --- a/.github/workflows/ci_dev.yml +++ b/.github/workflows/ci_dev.yml @@ -983,3 +983,28 @@ jobs: SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} run: | cloudos interactive-session status --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID + interactive_session_pause: + needs: interactive_session_create + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9"] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + cache: pip + cache-dependency-path: setup.py + - name: Install dependencies + run: | + pip install -e . + - name: Pause interactive session + env: + CLOUDOS_TOKEN: ${{ secrets.CLOUDOS_TOKEN_DEV }} + CLOUDOS_WORKSPACE_ID: ${{ secrets.CLOUDOS_WORKSPACE_ID_DEV }} + CLOUDOS_URL: "https://dev.sdlc.lifebit.ai" + SESSION_ID: ${{ needs.interactive_session_create.outputs.session_id }} + run: | + cloudos interactive-session pause --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID --yes diff --git a/CHANGELOG.md b/CHANGELOG.md index 48c741bd..134dcfa1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## lifebit-ai/cloudos-cli: changelog +## v2.85.0 (2026-03-20) + +### Feat + +- Adds pausing of an interactive session + ## v2.84.0 (2026-03-19) ### Feat diff --git a/README.md b/README.md index 15fa4298..99bb4482 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ Python package for interacting with CloudOS - [Interactive Sessions](#interactive-sessions) - [List Interactive Sessions](#list-interactive-sessions) - [Get Interactive Session Status](#get-interactive-session-status) + - [Pause Interactive Session](#pause-interactive-session) - [Create Interactive Session](#create-interactive-session) - [Datasets](#datasets) - [List Files](#list-files) @@ -1972,9 +1973,9 @@ The table displays sessions with pagination controls (press `n` for next page, ` ┏━━━━━━━━━┳━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┳━━━━━━━━┓ ┃ Status ┃ Name ┃ Type ┃ ID ┃ Owner ┃ ┡━━━━━━━━━╇━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━╇━━━━━━━━┩ -│ stopped │ cloudosR │ awsRstudio │ 69aee0dba197… │ Leila │ -│ running │ analysis-dev │ awsJupyterNotebook │ 69ae972a18f0… │ John │ -│ stopped │ test_session │ awsVSCode │ 69a996c098ab… │ James │ +│ paused │ cloudosR │ RStudio │ 69aee0dba197… │ Leila │ +│ running │ analysis-dev │ Jupyter │ 69ae972a18f0… │ John │ +│ paused │ test_session │ VS Code │ 69a996c098ab… │ James │ └─────────┴──────────────┴────────────────────┴───────────────┴────────┘ Total sessions: 15 @@ -2012,7 +2013,7 @@ Interactive session list saved to interactive_sessions_list.json You can filter sessions by status and other criteria: ```bash -# Filter by status (setup, initialising, running, scheduled, stopped) +# Filter by status (setup, initialising, running, scheduled, paused) cloudos interactive-session list --profile my_profile --filter-status running # Show only your own sessions @@ -2090,7 +2091,7 @@ Status changed: provisioning → running **Watch Mode Behavior** - **Pre-running sessions** (setup, initialising, scheduled): Watch mode will continuously poll and display status changes every 30 seconds (default) -- **Running/stopped sessions**: Watch mode will show a warning and display the current status instead +- **Running/paused sessions**: Watch mode will show a warning and display the current status instead Example with a running session: @@ -2159,6 +2160,161 @@ cloudos interactive-session status --session-id --profile my_profil # Creates: /tmp/session_status.csv ``` +#### Pause Interactive Session + +You can pause and terminate a running interactive session using the `cloudos interactive-session pause` command. This command gracefully shuts down the session and optionally saves session data before termination. + +**Basic Usage** + +Pause a session with confirmation: + +```bash +cloudos interactive-session pause --session-id --profile my_profile +``` + +The command displays a confirmation prompt: + +```console +About to pause session: 69bd11ca02326c5b3649f5c1 +Upload data before pausing: True +Force immediate termination: False +Continue? [y/N]: y + +✓ Session pause request sent successfully. +You can monitor the session status using: cloudos interactive-session status --session-id 69bd11ca02326c5b3649f5c1 +``` + +**Skip Confirmation Prompt** + +Use the `-y` or `--yes` flag to skip the confirmation prompt: + +```bash +cloudos interactive-session pause --session-id --profile my_profile -y +``` + +**Data Management Options** + +By default, session data is saved to S3 before pausing. Use `--no-upload` to skip data saving (use with caution): + +```bash +# Save session data before pausing (default) +cloudos interactive-session pause --session-id --profile my_profile + +# Skip saving data (use with caution) +cloudos interactive-session pause --session-id --profile my_profile --no-upload +``` + +**Termination Modes** + +**Graceful Shutdown (default)** + +Allows the session to clean up resources and save data before terminating: + +```bash +cloudos interactive-session pause --session-id --profile my_profile +``` + +**Force Immediate Termination** + +Bypass graceful shutdown for immediate termination (useful for stuck sessions): + +```bash +cloudos interactive-session pause --session-id --profile my_profile --force +``` + +Use `--force` with caution as it may not save session data properly. + +**Wait for Termination** + +Use the `--wait` flag to monitor the session until it reaches a terminal state: + +```bash +cloudos interactive-session pause --session-id --profile my_profile --wait +``` + +Output with `--wait`: + +```console +Pausing session... +Status: shutting_down +Status: uploading_data +Status: cleaning_up +Status: stopped +✓ Session paused successfully +``` + +**Error Handling** + +The command provides helpful error messages for common issues: + +```console +# Trying to pause an already paused session +Error: Cannot pause session - the session is already paused. +Tip: Check the session status with: cloudos interactive-session status --session-id + +# Trying to pause a session that is already being paused +Error: Cannot pause session - the session is already being paused. +Tip: Wait a moment and check status with: cloudos interactive-session status --session-id +``` + +**Examples** + +Basic pause with confirmation: + +```bash +cloudos interactive-session pause --session-id 688351ab6be610972db54a8e --workspace-id 687fb9905c45270e09db1e9a +``` + +Pause without saving data and skip confirmation: + +```bash +cloudos interactive-session pause --session-id 688351ab6be610972db54a8e --workspace-id 687fb9905c45270e09db1e9a --no-upload -y +``` + +Force pause and wait for termination: + +```bash +cloudos interactive-session pause --session-id 688351ab6be610972db54a8e --workspace-id 687fb9905c45270e09db1e9a --force -y --wait +``` + +Pause using profile configuration: + +```bash +cloudos interactive-session pause --session-id 688351ab6be610972db54a8e --profile my_profile --wait +``` + +Pause with verbose output: + +```bash +cloudos interactive-session pause --session-id 688351ab6be610972db54a8e --profile my_profile --verbose +``` + +**Options Reference** + +The command automatically loads from profile (via `@with_profile_config` decorator): +- **From Profile**: apikey, cloudos-url, workspace-id +- **Command Line**: Additional options and behaviors + +**Required:** +- `--session-id`: The session ID to pause (24-character hex string) + +**Optional Overrides from Profile:** +- `--apikey` (optional): Override API key from profile +- `--cloudos-url` (optional): Override CloudOS URL from profile +- `--workspace-id` (optional): Override workspace ID from profile + +**Optional Behavior Flags:** +- `--no-upload`: Don't save session data before pausing (default: saves data) +- `--force`: Force immediate termination, skip graceful shutdown (default: graceful). **Warning:** Shows a warning message that some data may not be saved. +- `--wait`: Wait for session to fully pause (default: return immediately after sending pause command) +- `-y, --yes`: Skip confirmation prompt (default: show confirmation) +- `--verbose`: Show detailed progress messages + +**Optional Connection:** +- `--disable-ssl-verification`: Disable SSL certificate verification (not recommended) +- `--ssl-cert`: Path to SSL certificate file +- `--profile`: Profile to use from config file (default: default profile) + #### Create Interactive Session You can create and start a new interactive session using the `cloudos interactive-session create` command. This command provisions a new virtual environment with your specified configuration. @@ -2293,7 +2449,7 @@ The output shows the session details including: - Session ID - Session name - Backend type (jupyter, vscode, rstudio, spark) -- Current status (scheduled, initialising, setup, running, stopped) +- Current status (scheduled, initialising, setup, running, paused) ### Datasets diff --git a/cloudos_cli/_version.py b/cloudos_cli/_version.py index 8e417a5e..a9962c60 100644 --- a/cloudos_cli/_version.py +++ b/cloudos_cli/_version.py @@ -1 +1 @@ -__version__ = '2.84.0' +__version__ = '2.85.0' diff --git a/cloudos_cli/clos.py b/cloudos_cli/clos.py index 08b0cd6f..5812f297 100644 --- a/cloudos_cli/clos.py +++ b/cloudos_cli/clos.py @@ -2313,7 +2313,7 @@ def get_interactive_session_list(self, team_id, page=None, limit=None, status=No if status: # status is a list of valid status values (user-friendly names) # Include both spellings and API names for flexibility - valid_statuses = ['setup', 'initialising', 'initializing', 'running', 'scheduled', 'stopped', 'aborted'] + valid_statuses = ['setup', 'initialising', 'initializing', 'running', 'scheduled', 'stopped', 'paused', 'aborted'] for s in status: if s.lower() not in valid_statuses: raise ValueError(f"Invalid status '{s}'. Valid values: {', '.join(valid_statuses)}") @@ -2326,6 +2326,7 @@ def get_interactive_session_list(self, team_id, page=None, limit=None, status=No 'running': 'ready', # API uses 'ready' for running sessions 'scheduled': 'scheduled', 'stopped': 'aborted', + 'paused': 'aborted', # 'paused' and 'stopped' both map to 'aborted' API status 'aborted': 'aborted' # Also accept 'aborted' as input } mapped_statuses = [status_mapping[s.lower()] for s in status] @@ -2417,5 +2418,70 @@ def create_interactive_session(self, team_id, payload, verify=True): # Return the full session object from response content = r.json() return content + + def abort_interactive_session(self, session_id, team_id, upload_on_close=True, force_abort=False, verify=True): + """Abort and stop a running interactive session. + + Parameters + ---------- + session_id : string + The session ID (MongoDB ObjectId) to abort. + team_id : string + The CloudOS team id (workspace id) where the session is running. + upload_on_close : bool, optional + If True, save session data to S3 before terminating. Default=True. + force_abort : bool, optional + If True, force immediate termination (skip graceful shutdown). Default=False. + verify: [bool|string], default=True + Whether to use SSL verification or not. Alternatively, if + a string is passed, it will be interpreted as the path to + the SSL certificate file. + + Returns + ------- + int + HTTP status code (204 for successful abort, no content returned). + """ + # Validate session_id + if not session_id or not isinstance(session_id, str): + raise ValueError("Invalid session_id: must be a non-empty string") + + # Validate team_id + if not team_id or not isinstance(team_id, str): + raise ValueError("Invalid team_id: must be a non-empty string") + + headers = { + "Content-type": "application/json", + "apikey": self.apikey + } + + # Build request body + payload = { + "uploadOnClose": upload_on_close, + "forceAbort": force_abort + } + + # Build URL with teamId query parameter + url = f"{self.cloudos_url}/api/v1/interactive-sessions/{session_id}/abort?teamId={team_id}" + + # Make the API request with PUT method + try: + r = requests.put( + url, + headers=headers, + data=json.dumps(payload), + verify=verify, + timeout=30 + ) + except Exception as e: + raise Exception(f"Failed to abort interactive session: {str(e)}") + + if r.status_code >= 400: + if r.status_code == 404: + raise ValueError(f"Session not found: {session_id}") + raise BadRequestException(r) + + # Return the status code (204 No Content is success) + return r.status_code diff --git a/cloudos_cli/interactive_session/cli.py b/cloudos_cli/interactive_session/cli.py index d2e639b2..04d06a96 100644 --- a/cloudos_cli/interactive_session/cli.py +++ b/cloudos_cli/interactive_session/cli.py @@ -27,6 +27,8 @@ export_session_status_csv, map_status, PRE_RUNNING_STATUSES, + format_stop_success_output, + poll_session_termination, ) from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL from cloudos_cli.utils.cli_helpers import pass_debug_to_subcommands @@ -54,7 +56,7 @@ def interactive_session(): required=True) @click.option('--filter-status', multiple=True, - type=click.Choice(['setup', 'initialising', 'running', 'scheduled', 'stopped'], case_sensitive=False), + type=click.Choice(['setup', 'initialising', 'running', 'scheduled', 'paused'], case_sensitive=False), help='Filter sessions by status. Can be specified multiple times to filter by multiple statuses.') @click.option('--limit', type=int, @@ -199,7 +201,7 @@ def fetch_page(page_num): if len(sessions) == 0: if filter_status: # Show helpful message when filtering returns no results - status_flow = 'scheduled → initialising → setup → running → stopped' + status_flow = 'scheduled → initialising → setup → running → paused' click.secho(f'No interactive sessions found in the requested status.', fg='yellow', err=True) click.secho(f'Session status flow: {status_flow}', fg='cyan', err=True) elif output_format == 'stdout': @@ -232,7 +234,7 @@ def fetch_page(page_num): raise SystemExit(1) # Check if the error is related to status filtering elif filter_status and ('400' in error_str or 'Invalid' in error_str): - status_flow = 'scheduled → initialising → setup → running → stopped' + status_flow = 'scheduled → initialising → setup → running → paused' click.secho(f'No interactive sessions found in the requested status.', fg='yellow', err=True) click.secho(f'Session status flow: {status_flow}', fg='cyan', err=True) raise SystemExit(1) @@ -787,7 +789,7 @@ def get_session_status(ctx, if display_status == 'running': click.secho('✓ Session is now running and ready to use!', fg='green') break - elif display_status in ['stopped', 'terminated']: + elif display_status in ['paused', 'terminated']: click.secho(f'⚠ Session reached terminal state: {display_status}', fg='yellow') break @@ -860,3 +862,226 @@ def get_session_status(ctx, else: click.secho(f'Error: Failed to retrieve session status: {str(e)}', fg='red', err=True) raise SystemExit(1) + + +@interactive_session.command('pause') +@click.option('--session-id', + help='The session ID to pause (24-character hex string).', + required=True) +@click.option('-k', + '--apikey', + help='Your CloudOS API key', + required=False) +@click.option('-c', + '--cloudos-url', + help=(f'The CloudOS url you are trying to access to. Default={CLOUDOS_URL}.'), + default=CLOUDOS_URL, + required=False) +@click.option('--workspace-id', + help='The specific CloudOS workspace id.', + required=False) +@click.option('--no-upload', + is_flag=True, + help='Don\'t save session data before pausing (use with caution).') +@click.option('--force', + is_flag=True, + help='Force immediate termination and skip confirmation prompt.') +@click.option('--wait', + is_flag=True, + help='Wait for session to fully pause.') +@click.option('--yes', '-y', + 'skip_confirmation', + is_flag=True, + help='Skip confirmation prompt.') +@click.option('--verbose', + help='Whether to print information messages or not.', + is_flag=True) +@click.option('--disable-ssl-verification', + help=('Disable SSL certificate verification. Please, remember that this option is ' + + 'not generally recommended for security reasons.'), + is_flag=True) +@click.option('--ssl-cert', + help='Path to your SSL certificate file.') +@click.option('--profile', help='Profile to use from the config file', default=None) +@click.pass_context +@with_profile_config(required_params=['apikey', 'workspace_id']) +def pause_session(ctx, + session_id, + apikey, + cloudos_url, + workspace_id, + no_upload, + force, + wait, + skip_confirmation, + verbose, + disable_ssl_verification, + ssl_cert, + profile): + """Pause a running interactive session.""" + + verify_ssl = ssl_selector(disable_ssl_verification, ssl_cert) + + # Validate session ID format + if not validate_session_id(session_id): + click.secho(f'Error: Invalid session ID format. Expected 24-character hex string, got: {session_id}', fg='red', err=True) + raise SystemExit(1) + + if verbose: + print('Executing pause interactive session...') + print('\t...Preparing objects') + + try: + # Check session status BEFORE prompting for confirmation + if verbose: + print('\t...Checking session status') + + try: + session_response = get_interactive_session_status( + cloudos_url=cloudos_url, + apikey=apikey, + session_id=session_id, + team_id=workspace_id, + verify_ssl=verify_ssl, + verbose=False + ) + except Exception as e: + # Handle invalid session ID or API errors + error_msg = str(e).lower() + if 'not found' in error_msg or '404' in error_msg: + click.secho(f'Error: Session ID not found: {session_id}', fg='red', err=True) + else: + click.secho(f'Error: Unable to retrieve session status: {str(e)}', fg='red', err=True) + raise SystemExit(1) + + # Check if session is already paused or terminated + api_status = session_response.get('status', '') + + + if api_status == 'aborted': + click.secho(f'Error: Cannot pause session - the session is already paused.', fg='red', err=True) + click.secho(f'Tip: Check the session status with: cloudos interactive-session status --session-id {session_id}', fg='yellow', err=True) + raise SystemExit(1) + elif api_status == 'aborting': + click.secho(f'Error: Cannot pause session - the session is already being paused.', fg='red', err=True) + click.secho(f'Tip: Wait a moment and check status with: cloudos interactive-session status --session-id {session_id}', fg='yellow', err=True) + raise SystemExit(1) + + if api_status == 'terminated': + click.secho(f'Error: Session is terminated and cannot be paused.', fg='red', err=True) + raise SystemExit(1) + + # Show confirmation prompt unless --yes or --force flag is used + if not skip_confirmation and not force: + click.echo(f'About to pause session: {session_id}') + click.echo(f'Upload data before pausing: {not no_upload}') + click.echo(f'Force immediate termination: {force}') + + # Get user confirmation + try: + response = click.prompt('Continue? [y/N]', type=str, default='N') + if response.lower() != 'y': + click.echo('Cancelled.') + raise SystemExit(0) + except KeyboardInterrupt: + click.secho('\n⚠ Operation cancelled by user.', fg='yellow', err=True) + raise SystemExit(0) + + # Prepare abort parameters + upload_on_close = not no_upload # Invert no_upload to get upload_on_close + force_abort = force + + # Create Cloudos client and abort session + cl = Cloudos(cloudos_url, apikey, None) + + if verbose: + print('\t...Sending abort request to CloudOS') + + # Call the abort endpoint + status_code = cl.abort_interactive_session( + session_id=session_id, + team_id=workspace_id, + upload_on_close=upload_on_close, + force_abort=force_abort, + verify=verify_ssl + ) + + if verbose: + print(f'\t✓ Abort request sent successfully (HTTP {status_code})') + + # Show force abort warning if applicable + if force: + click.secho('\n⚠ Warning: Session was force-aborted by the user. Some data may have not been saved.', fg='yellow', err=True) + + # If --wait flag is set, poll until session is paused + if wait: + if verbose: + print('\t...Waiting for session to fully pause') + + try: + final_response = poll_session_termination( + cloudos_url=cloudos_url, + apikey=apikey, + session_id=session_id, + team_id=workspace_id, + max_wait=300, # 5 minutes timeout + poll_interval=5, # Poll every 5 seconds + verify_ssl=verify_ssl + ) + + # Display final status (pass raw API response, not transformed data) + format_stop_success_output(final_response, wait=True) + + except TimeoutError as e: + click.secho(f'⚠ Timeout: {str(e)}', fg='yellow', err=True) + click.echo('The session pause command has been sent, but the session did not fully terminate within the timeout period.') + click.echo(f'You can check the session status using: cloudos interactive-session status --session-id {session_id} --profile {profile or "default"}') + raise SystemExit(1) + else: + # Show success message without waiting + click.secho('✓ Session pause request sent successfully.', fg='green') + click.echo(f'You can monitor the session status using: cloudos interactive-session status --session-id {session_id} --profile {profile or "default"}') + + except ValueError as e: + # Handle validation errors + click.secho(f'Error: {str(e)}', fg='red', err=True) + raise SystemExit(1) + + except PermissionError as e: + # Handle authentication/permission errors + click.secho(f'Error: {str(e)}', fg='red', err=True) + if '401' in str(e) or 'Unauthorized' in str(e): + click.secho('Please check your API credentials (apikey and cloudos-url).', fg='yellow', err=True) + raise SystemExit(1) + + except BadRequestException as e: + # Handle API errors with better messages + error_str = str(e) + # Show the original error for other bad request errors + click.secho(f'Error: {str(e)}', fg='red', err=True) + raise SystemExit(1) + + except KeyboardInterrupt: + click.secho('\n⚠ Operation interrupted by user.', fg='yellow', err=True) + raise SystemExit(0) + + except Exception as e: + error_str = str(e) + # Check for network errors + if 'Failed to resolve' in error_str or 'Name or service not known' in error_str: + click.secho(f'Error: Unable to connect to CloudOS. Please verify the CloudOS URL is correct.', fg='red', err=True) + elif '401' in error_str or 'Unauthorized' in error_str: + click.secho(f'Error: Failed to pause session. Please check your credentials.', fg='red', err=True) + elif 'Session not found' in error_str: + click.secho(f'Error: Session not found. Please check the session ID.', fg='red', err=True) + elif 'aborted in aborted status' in error_str.lower() or 'aborted in aborting status' in error_str.lower(): + # Session is already paused/pausing + if 'aborted status' in error_str.lower(): + click.secho(f'Error: Cannot pause session - the session is already paused.', fg='red', err=True) + click.secho(f'Tip: Check the session status with: cloudos interactive-session status --session-id {session_id}', fg='yellow', err=True) + else: + click.secho(f'Error: Cannot pause session - the session is already being paused.', fg='red', err=True) + click.secho(f'Tip: Wait a moment and check status with: cloudos interactive-session status --session-id {session_id}', fg='yellow', err=True) + else: + click.secho(f'Error: Failed to pause session: {str(e)}', fg='red', err=True) + raise SystemExit(1) diff --git a/cloudos_cli/interactive_session/interactive_session.py b/cloudos_cli/interactive_session/interactive_session.py index b1686578..53096ccb 100644 --- a/cloudos_cli/interactive_session/interactive_session.py +++ b/cloudos_cli/interactive_session/interactive_session.py @@ -5,6 +5,8 @@ import re import json import time +import json +import time from datetime import datetime, timedelta, timezone from rich.table import Table from rich.console import Console @@ -14,7 +16,6 @@ from cloudos_cli.utils.requests import retry_requests_get - def validate_instance_type(instance_type, execution_platform='aws'): """Validate instance type format for the given execution platform. @@ -424,7 +425,6 @@ def create_interactive_session_list_table(sessions, pagination_metadata=None, se break - def process_interactive_session_list(sessions, all_fields=False): """Process interactive sessions data into a pandas DataFrame. @@ -519,11 +519,11 @@ def _format_session_field(field_name, value): status_lower = str(value).lower() # Map API statuses to display values # API 'ready' and 'aborted' are mapped to user-friendly names - display_status = 'running' if status_lower == 'ready' else ('stopped' if status_lower == 'aborted' else value) + display_status = 'running' if status_lower == 'ready' else ('paused' if status_lower == 'aborted' else value) if status_lower in ['ready', 'running']: return f'[bold green]{display_status}[/bold green]' - elif status_lower in ['stopped', 'aborted']: + elif status_lower in ['paused', 'aborted']: return f'[bold red]{display_status}[/bold red]' elif status_lower in ['setup', 'initialising', 'initializing', 'scheduled']: return f'[bold yellow]{display_status}[/bold yellow]' @@ -1229,7 +1229,12 @@ def format_session_creation_table(session_data, instance_type=None, storage_size table.add_row("Session ID", session_data.get('_id', 'N/A')) table.add_row("Name", session_data.get('name', 'N/A')) - table.add_row("Backend", session_data.get('interactiveSessionType', 'N/A')) + + # Map backend type to friendly name + api_backend = session_data.get('interactiveSessionType', 'N/A') + backend_display = _map_session_type_to_friendly_name(api_backend) if api_backend != 'N/A' else 'N/A' + table.add_row("Backend", backend_display) + table.add_row("Status", session_data.get('status', 'N/A')) # Try to get instance type from response, fallback to provided value @@ -1304,40 +1309,28 @@ def format_session_creation_table(session_data, instance_type=None, storage_size # Interactive Session Status Helper Functions # ============================================================================ -# Backend type mapping for status display -BACKEND_MAPPING = { - 'awsJupyterNotebook': 'Jupyter Notebook', - 'azureJupyterNotebook': 'Jupyter Notebook', - 'awsVSCode': 'VS Code', - 'azureVSCode': 'VS Code', - 'awsJupyterSparkNotebook': 'Spark', - 'azureJupyterSparkNotebook': 'Spark', - 'awsRstudio': 'RStudio', - 'azureRstudio': 'RStudio', -} - # Status color mapping for Rich terminal STATUS_COLORS = { 'running': 'green', - 'stopped': 'red', + 'paused': 'red', 'terminated': 'red', 'provisioning': 'yellow', 'scheduled': 'yellow', } # Terminal states where watch mode should exit -TERMINAL_STATES = {'running', 'stopped', 'terminated'} +TERMINAL_STATES = {'running', 'paused', 'terminated'} # Status mapping from API to user-friendly display API_STATUS_MAPPING = { 'ready': 'running', # API returns 'ready' for running sessions - 'aborted': 'stopped', # API returns 'aborted' for stopped sessions + 'aborted': 'paused', # API returns 'aborted' for paused sessions 'setup': 'setup', 'initialising': 'initialising', 'initializing': 'initialising', 'scheduled': 'scheduled', 'running': 'running', # Some endpoints may return 'running' - 'stopped': 'stopped', # Some endpoints may return 'stopped' + 'stopped': 'paused', # Some endpoints may return 'stopped' - map to 'paused' 'terminated': 'terminated', } @@ -1369,16 +1362,11 @@ def format_duration(seconds: int) -> str: return " ".join(parts) -def map_backend_type(api_backend: str) -> str: - """Map API backend type to user-friendly display name.""" - return BACKEND_MAPPING.get(api_backend, api_backend) - - def map_status(api_status: str) -> str: """Map API status value to user-friendly display status. Converts API status values (like 'ready', 'aborted') to display values - (like 'running', 'stopped') matching the list command. + (like 'running', 'paused') matching the list command. """ return API_STATUS_MAPPING.get(api_status, api_status) @@ -1535,7 +1523,6 @@ def get_session_status(self, session_id: str, team_id: str) -> dict: raise RuntimeError(f"Failed to connect to CloudOS: {str(e)}") - class OutputFormatter: """Handles formatting output in different formats.""" @@ -1647,7 +1634,7 @@ def __init__(self, api_client: InteractiveSessionAPI, def watch(self, verbose: bool = False) -> dict: """Continuously poll session status until reaching terminal state. - Terminal states: running, stopped, terminated + Terminal states: running, paused, terminated Handles Ctrl+C gracefully. """ @@ -1711,7 +1698,7 @@ def transform_session_response(api_response: dict) -> dict: # Map backend type api_backend = api_response.get('interactiveSessionType', '') - backend_type = map_backend_type(api_backend) + backend_type = _map_session_type_to_friendly_name(api_backend) # Extract user info user = api_response.get('user', {}) @@ -1874,3 +1861,158 @@ def format_session_status_table(session_data: dict, cloudos_url: str = None) -> CloudOS URL for creating links """ OutputFormatter.format_stdout(session_data, cloudos_url or '') + + +def confirm_session_stop(session_data: dict, no_upload: bool = False, force: bool = False) -> None: + """Display session termination confirmation details. + + Parameters + ---------- + session_data : dict + Session data from API response + no_upload : bool + Whether data upload on close is disabled + force : bool + Whether force abort is enabled + """ + console = Console() + + session_name = session_data.get('name', 'Unknown') + session_id = session_data.get('_id', 'Unknown') + status = map_status(session_data.get('status', 'unknown')) + cost_per_hour = session_data.get('costPerHour', 0) + + # Create confirmation table + table = Table(title=f"About to stop session: {session_name}", title_style="bold yellow") + table.add_column("Property", style="cyan", no_wrap=True) + table.add_column("Value", style="green") + + table.add_row("Session ID", session_id) + table.add_row("Current Status", status) + + if not no_upload: + table.add_row("Data Action", "Will be saved before stopping") + else: + table.add_row("Data Action", "⚠ Will NOT be saved (--no-upload)") + + if force: + table.add_row("Termination", "⚠ FORCED (skip graceful shutdown)") + else: + table.add_row("Termination", "Graceful shutdown") + + if cost_per_hour: + table.add_row("Cost/Hour", f"${cost_per_hour:.2f}") + + console.print(table) + + +def format_stop_success_output(session_data: dict, wait: bool = False) -> None: + """Display successful session stop output. + + Parameters + ---------- + session_data : dict + Final session data from API response + wait : bool + Whether the command waited for full termination + """ + from rich.console import Console + from rich.panel import Panel + + console = Console() + session_name = session_data.get('name', 'Unknown') + session_id = session_data.get('_id', 'Unknown') + status = map_status(session_data.get('status', 'unknown')) + total_cost = session_data.get('totalCostInUsd', 0) + total_runtime = session_data.get('totalRunningTimeInSeconds', 0) + + # Format runtime + runtime_str = format_duration(total_runtime) if total_runtime else 'N/A' + + # Build message + message = f"Session paused successfully\n" + message += f" Session ID: {session_id}\n" + message += f" Final status: {status}\n" + + if total_cost: + message += f" Total cost: ${total_cost:.2f}\n" + + if total_runtime: + message += f" Total runtime: {runtime_str}" + + # Display success message + console.print(Panel(message, title="✓ Session Stop Complete", style="bold green")) + + +def poll_session_termination(cloudos_url: str, apikey: str, session_id: str, team_id: str, + max_wait: int = 300, poll_interval: int = 5, verify_ssl: bool = True) -> dict: + """Poll session status until it reaches a terminal state. + + Parameters + ---------- + cloudos_url : str + CloudOS API URL + apikey : str + API key for authentication + session_id : str + Session ID to monitor + team_id : str + Team/workspace ID + max_wait : int + Maximum time to wait in seconds (default: 300 = 5 minutes) + poll_interval : int + Polling interval in seconds (default: 5) + verify_ssl : bool + Whether to verify SSL certificates + + Returns + ------- + dict + Final session status response + + Raises + ------ + TimeoutError + If session doesn't reach terminal state within max_wait + """ + from rich.console import Console + + console = Console() + start_time = time.time() + previous_status = None + + with console.status("[bold yellow]Pausing session...", spinner='dots'): + while True: + elapsed = time.time() - start_time + + # Fetch current status + session_response = get_interactive_session_status( + cloudos_url=cloudos_url, + apikey=apikey, + session_id=session_id, + team_id=team_id, + verify_ssl=verify_ssl, + verbose=False + ) + + current_status = map_status(session_response.get('status', '')) + + # Print status changes + if current_status != previous_status: + console.log(f"Status: {current_status}") + previous_status = current_status + + # Check if terminal state reached + if current_status in ['paused', 'terminated']: + console.print("[bold green]✓ Session paused successfully") + return session_response + + # Check timeout + if elapsed > max_wait: + raise TimeoutError( + f"Session did not reach terminal state within {max_wait} seconds. " + f"Current status: {current_status}" + ) + + # Wait before next poll + time.sleep(poll_interval)