diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d4492d0f..acc063c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -921,6 +921,8 @@ jobs: run: | echo q |cloudos interactive-session list --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID interactive_session_create: + outputs: + session_id: ${{ steps.get-session-id.outputs.session_id }} runs-on: ubuntu-latest strategy: matrix: @@ -936,12 +938,46 @@ jobs: - name: Install dependencies run: | pip install -e . - - name: Run tests + - name: Create interactive session + id: get-session-id env: CLOUDOS_TOKEN: ${{ secrets.CLOUDOS_TOKEN_ADAPT }} CLOUDOS_WORKSPACE_ID: ${{ secrets.CLOUDOS_WORKSPACE_ID_ADAPT }} + CLOUDOS_URL: "https://cloudos.lifebit.ai" PROJECT_NAME: "cloudos-cli-tests" + SESSION_NAME: "ci_test_cli" + SESSION_TYPE: "jupyter" + SHUTDOWN_IN: "10m" + run: | + cloudos interactive-session create --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --project-name "$PROJECT_NAME" --name $SESSION_NAME --session-type $SESSION_TYPE --shutdown-in $SHUTDOWN_IN 2>&1 | tee out.txt + SESSION_ID=$(grep -oP '(?<=/view/)[a-f0-9]{24}' out.txt | head -1) + echo "session_id=$SESSION_ID" >> $GITHUB_OUTPUT + interactive_session_status: + 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: Get session status + 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 create --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --project-name "$PROJECT_NAME" --name ci_test_cli --session-type jupyter --shutdown-in 10m + cloudos interactive-session status --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID + + + diff --git a/.github/workflows/ci_az.yml b/.github/workflows/ci_az.yml index 3488865a..35e187db 100644 --- a/.github/workflows/ci_az.yml +++ b/.github/workflows/ci_az.yml @@ -707,6 +707,8 @@ jobs: run: | echo q |cloudos interactive-session list --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID interactive_session_create: + outputs: + session_id: ${{ steps.get-session-id.outputs.session_id }} runs-on: ubuntu-latest strategy: matrix: @@ -722,13 +724,45 @@ jobs: - name: Install dependencies run: | pip install -e . - - name: Run tests + - name: Create interactive session + id: get-session-id env: CLOUDOS_TOKEN: ${{ secrets.CLOUDOS_TOKEN_AZURE }} CLOUDOS_WORKSPACE_ID: ${{ secrets.CLOUDOS_WORKSPACE_ID_AZURE }} CLOUDOS_URL: "https://dev.sdlc.lifebit.ai" PROJECT_NAME: "cloudos-cli-tests" + SESSION_NAME: "ci_test_cli" + SESSION_TYPE: "jupyter" + SHUTDOWN_IN: "10m" + EXECUTION_PLATFORM: "azure" INSTANCE_TYPE: "Standard_D4as_v4" run: | - cloudos interactive-session create --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --project-name "$PROJECT_NAME" --name ci_test_cli --session-type jupyter --shutdown-in 10m --execution-platform azure --instance $INSTANCE_TYPE + cloudos interactive-session create --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --project-name "$PROJECT_NAME" --name $SESSION_NAME --session-type $SESSION_TYPE --shutdown-in $SHUTDOWN_IN --execution-platform $EXECUTION_PLATFORM --instance $INSTANCE_TYPE 2>&1 | tee out.txt + SESSION_ID=$(grep -oP '(?<=/view/)[a-f0-9]{24}' out.txt | head -1) + echo "session_id=$SESSION_ID" >> $GITHUB_OUTPUT + interactive_session_status: + 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: Get session status + 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 status --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID diff --git a/.github/workflows/ci_dev.yml b/.github/workflows/ci_dev.yml index f7471f6d..08aecdd5 100644 --- a/.github/workflows/ci_dev.yml +++ b/.github/workflows/ci_dev.yml @@ -927,6 +927,8 @@ jobs: run: | echo q |cloudos interactive-session list --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID interactive_session_create: + outputs: + session_id: ${{ steps.get-session-id.outputs.session_id }} runs-on: ubuntu-latest strategy: matrix: @@ -942,11 +944,42 @@ jobs: - name: Install dependencies run: | pip install -e . - - name: Run tests + - name: Create interactive session + id: get-session-id env: - CLOUDOS_TOKEN: ${{ secrets.CLOUDOS_TOKEN_DEV }} - CLOUDOS_WORKSPACE_ID: ${{ secrets.CLOUDOS_WORKSPACE_ID_DEV }} - CLOUDOS_URL: "https://dev.sdlc.lifebit.ai" - PROJECT_NAME: "cloudos-cli-tests" + CLOUDOS_TOKEN: ${{ secrets.CLOUDOS_TOKEN_DEV }} + CLOUDOS_WORKSPACE_ID: ${{ secrets.CLOUDOS_WORKSPACE_ID_DEV }} + CLOUDOS_URL: "https://dev.sdlc.lifebit.ai" + PROJECT_NAME: "cloudos-cli-tests" + SESSION_NAME: "ci_test_cli" + SESSION_TYPE: "jupyter" + SHUTDOWN_IN: "10m" + run: | + cloudos interactive-session create --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --project-name "$PROJECT_NAME" --name $SESSION_NAME --session-type $SESSION_TYPE --shutdown-in $SHUTDOWN_IN 2>&1 | tee out.txt + SESSION_ID=$(grep -oP '(?<=/view/)[a-f0-9]{24}' out.txt | head -1) + echo "session_id=$SESSION_ID" >> $GITHUB_OUTPUT + interactive_session_status: + 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: Get session status + 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 create --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --project-name "$PROJECT_NAME" --name ci_test_cli --session-type jupyter --shutdown-in 10m + cloudos interactive-session status --cloudos-url $CLOUDOS_URL --apikey $CLOUDOS_TOKEN --workspace-id $CLOUDOS_WORKSPACE_ID --session-id $SESSION_ID diff --git a/CHANGELOG.md b/CHANGELOG.md index 99da5c72..48c741bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## lifebit-ai/cloudos-cli: changelog +## v2.84.0 (2026-03-19) + +### Feat + +- Adds interactive session status + ## v2.83.0 (2026-03-18) ### Feat diff --git a/README.md b/README.md index 7155eda5..15fa4298 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,7 @@ Python package for interacting with CloudOS - [Use multiple projects for files in `--parameter` option](#use-multiple-projects-for-files-in---parameter-option) - [Interactive Sessions](#interactive-sessions) - [List Interactive Sessions](#list-interactive-sessions) + - [Get Interactive Session Status](#get-interactive-session-status) - [Create Interactive Session](#create-interactive-session) - [Datasets](#datasets) - [List Files](#list-files) @@ -1952,7 +1953,7 @@ Interactive sessions allow you to work within the platform using different virtu You can get a list of all interactive sessions in your workspace by running `cloudos interactive-session list`. The command can produce three different output formats that can be selected using the `--output-format` option: -- **stdout** (default): Displaysa table directly in the terminal with interactive pagination +- **stdout** (default): Displays a table directly in the terminal with interactive pagination - **csv**: Saves session data to a CSV file with a minimum predefined set of columns by default, or all available columns using the `--all-fields` parameter - **json**: Saves complete session information to a JSON file with all available fields @@ -2035,6 +2036,128 @@ cloudos interactive-session list --profile my_profile --table-columns "status,na Available columns: `backend`, `cost`, `cost_limit`, `created_at`, `id`, `instance`, `name`, `owner`, `project`, `resources`, `runtime`, `saved_at`, `spot`, `status`, `time_left`, `type`, `version` +#### Get Interactive Session Status + +You can retrieve detailed status information for a specific interactive session using the `cloudos interactive-session status` command. This command provides comprehensive information about the session including its current state, resource allocation, costs, and more. + +**Basic Usage** + +Get the status of a session: + +```bash +cloudos interactive-session status --session-id --profile my_profile +``` + +The command displays session information in a formatted table: + +```console +╔════════════════════╦═════════════════════════════════════════════════════╗ +║ Property ║ Value ║ +╠════════════════════╬═════════════════════════════════════════════════════╣ +║ Session ID ║ 69bc00cb1488084e5a6cae70 ║ +║ Name ║ analysis-dev (linked) ║ +║ Status ║ running ║ +║ Backend ║ awsJupyterNotebook ║ +║ Owner ║ John Doe ║ +║ Project ║ research ║ +║ Instance Type ║ c5.xlarge ║ +║ Storage ║ 50 GB ║ +║ Cost ║ $2.45/hour ║ +║ Runtime ║ 2h 15m 30s ║ +║ Created At ║ 2024-03-19 10:30:00 UTC ║ +║ Last Saved ║ 2024-03-19 12:30:00 UTC ║ +║ Auto-Shutdown At ║ 2024-03-19 18:30:00 UTC ║ +╚════════════════════╩═════════════════════════════════════════════════════╝ +``` + +**Watch Mode for Provisioning Sessions** + +Use the `--watch` flag to continuously monitor a session's status as it provisions, with real-time status change notifications: + +```bash +cloudos interactive-session status --session-id --profile my_profile --watch +``` + +Watch mode automatically tracks status changes and polls until the session reaches a terminal state: + +```console +Session 69bc00cb1488084e5a6cae70 currently is in initialising... +Status changed: initialising → provisioning +Status changed: provisioning → running +✓ Session is now running and ready to use! +``` + +**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 + +Example with a running session: + +```bash +cloudos interactive-session status --session-id --profile my_profile --watch +``` + +```console +⚠ Warning: Watch mode only works for pre-running statuses (setup, initialising, scheduled). Current status: running. Showing session status instead. +[session status table displayed] +``` + +**Polling Interval** + +Customize the polling interval for watch mode: + +```bash +# Poll every 15 seconds instead of default 30 +cloudos interactive-session status --session-id --profile my_profile --watch --watch-interval 15 +``` + +**Watch Mode Timeout** + +Set a maximum time to wait for the session to reach running state. The `--max-wait-time` option accepts human-friendly duration formats: + +```bash +# 30 minutes (default) +cloudos interactive-session status --session-id --profile my_profile --watch + +# 5 minutes +cloudos interactive-session status --session-id --profile my_profile --watch --max-wait-time 5m + +# 2 hours +cloudos interactive-session status --session-id --profile my_profile --watch --max-wait-time 2h + +# 1 day +cloudos interactive-session status --session-id --profile my_profile --watch --max-wait-time 1d + +# 60 seconds +cloudos interactive-session status --session-id --profile my_profile --watch --max-wait-time 60s +``` + +**Supported timeout formats:** +- `30s` - seconds +- `5m` - minutes +- `2h` - hours +- `1d` - days + +If the session does not reach running state within the specified timeout, the watch mode exits with a clear message: + +```console +Timeout: Session did not reach running state within 30m. Current status: provisioning. Exiting watch mode. +``` + +**Output Formats** + +Save session status to a file: + +```bash +# Save as JSON +cloudos interactive-session status --session-id --profile my_profile --output-format json --output-basename /tmp/session_status +# Creates: /tmp/session_status.json + +# Save as CSV +cloudos interactive-session status --session-id --profile my_profile --output-format csv --output-basename /tmp/session_status +# Creates: /tmp/session_status.csv +``` #### Create Interactive Session diff --git a/cloudos_cli/_version.py b/cloudos_cli/_version.py index 23b8a670..8e417a5e 100644 --- a/cloudos_cli/_version.py +++ b/cloudos_cli/_version.py @@ -1 +1 @@ -__version__ = '2.83.0' +__version__ = '2.84.0' diff --git a/cloudos_cli/clos.py b/cloudos_cli/clos.py index 55e68ffc..08b0cd6f 100644 --- a/cloudos_cli/clos.py +++ b/cloudos_cli/clos.py @@ -2418,42 +2418,4 @@ def create_interactive_session(self, team_id, payload, verify=True): content = r.json() return content - ## FOR FUTURE COMMANDS IMPLEMENTATION - # def get_interactive_session(self, team_id, session_id, verify=True): - # """Get details of a specific interactive session. - - # Parameters - # ---------- - # team_id : string - # The CloudOS team id (workspace id). - # session_id : string - # The interactive session id (MongoDB ObjectId). - # verify: [bool|string], default=True - # Whether to use SSL verification or not. - - # Returns - # ------- - # dict - # Session object with current status and full details. - # """ - # if not team_id or not isinstance(team_id, str): - # raise ValueError("Invalid team_id: must be a non-empty string") - - # if not session_id or not isinstance(session_id, str): - # raise ValueError("Invalid session_id: must be a non-empty string") - - # headers = { - # "Content-type": "application/json", - # "apikey": self.apikey - # } - - # # Build URL for getting specific session - # url = f"{self.cloudos_url}/api/v2/interactive-sessions/{session_id}?teamId={team_id}" - - # r = retry_requests_get(url, headers=headers, verify=verify) - - # if r.status_code >= 400: - # raise BadRequestException(r) - # content = r.json() - # return content diff --git a/cloudos_cli/interactive_session/cli.py b/cloudos_cli/interactive_session/cli.py index 0956cc33..d2e639b2 100644 --- a/cloudos_cli/interactive_session/cli.py +++ b/cloudos_cli/interactive_session/cli.py @@ -2,6 +2,7 @@ import rich_click as click import json +import time from cloudos_cli.clos import Cloudos from cloudos_cli.datasets import Datasets from cloudos_cli.utils.errors import BadRequestException @@ -11,16 +12,24 @@ process_interactive_session_list, save_interactive_session_list_to_csv, parse_shutdown_duration, + parse_watch_timeout_duration, parse_data_file, parse_link_path, build_session_payload, format_session_creation_table, resolve_data_file_id, - validate_instance_type + validate_session_id, + validate_instance_type, + get_interactive_session_status, + format_session_status_table, + transform_session_response, + export_session_status_json, + export_session_status_csv, + map_status, + PRE_RUNNING_STATUSES, ) from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL from cloudos_cli.utils.cli_helpers import pass_debug_to_subcommands -from cloudos_cli.utils.requests import retry_requests_get # Create the interactive_session group @@ -602,6 +611,9 @@ def create_session(ctx, s3_mounts=parsed_s3_mounts ) + # Output session link in greppable format for CI/automation + click.echo(f"Session link: {cloudos_url}/app/data-science/interactive-analysis/view/{session_id}") + if verbose: print('\tSession creation completed successfully!') @@ -623,3 +635,228 @@ def create_session(ctx, else: click.secho(f'Error: {str(e)}', fg='red', err=True) raise SystemExit(1) + + + +@interactive_session.command('status') +@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('--session-id', + help='The session ID to retrieve status for (24-character hex string).', + required=True) +@click.option('--workspace-id', + help='The specific CloudOS workspace id.', + required=False) +@click.option('--output-format', + help='Output format for session status.', + type=click.Choice(['stdout', 'csv', 'json'], case_sensitive=False), + default='stdout') +@click.option('--output-basename', + help=('Output file base name to save session status. ' + + 'Default=interactive_session_status'), + default='interactive_session_status', + required=False) +@click.option('--watch', + is_flag=True, + help='Continuously poll status until session reaches running state (only for pre-running statuses).') +@click.option('--watch-interval', + type=int, + default=30, + help='Poll interval in seconds when using --watch. Default=30.') +@click.option('--max-wait-time', + type=str, + default='30m', + help='Maximum time to wait for session in watch mode. Accepts formats: 30s, 5m, 2h, 1d. Default=30m (30 minutes).') +@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 get_session_status(ctx, + apikey, + cloudos_url, + session_id, + workspace_id, + output_format, + output_basename, + watch, + watch_interval, + max_wait_time, + verbose, + disable_ssl_verification, + ssl_cert, + profile): + """Get status of an 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) + + # Validate watch-interval + if watch_interval <= 0: + click.secho(f'Error: --watch-interval must be a positive number, got: {watch_interval}', fg='red', err=True) + raise SystemExit(1) + + # Parse and validate max-wait-time + try: + max_wait_time_seconds = parse_watch_timeout_duration(max_wait_time) + except ValueError as e: + click.secho(f'Error: Invalid --max-wait-time format: {str(e)}', fg='red', err=True) + raise SystemExit(1) + + # Validate output format + if output_format.lower() not in ['stdout', 'csv', 'json']: + click.secho(f'Error: Invalid output format. Must be one of: stdout, csv, json', fg='red', err=True) + raise SystemExit(1) + + if verbose: + print('Executable: get interactive session status...') + print('\t...Preparing objects') + + try: + # Get initial status + if verbose: + print(f'\tRetrieving session status from: {cloudos_url}') + + 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=verbose + ) + + if verbose: + print(f'\t✓ Session retrieved successfully') + + # Get mapped status for display + api_status = session_response.get('status', '') + display_status = map_status(api_status) + + # Apply watch mode if requested + if watch: + # Check if watch mode is appropriate for this session status + if display_status not in PRE_RUNNING_STATUSES: + click.secho( + f'⚠ Warning: Watch mode only works for pre-running statuses (setup, initialising, scheduled). ' + f'Current status: {display_status}. Showing session status instead.', + fg='yellow', + err=True + ) + else: + # Print initial status message before starting watch + click.echo(f'Session {session_id} currently is in {display_status}...') + + start_time = time.time() + previous_status = display_status # Track previous status to detect changes + + while True: + # Get current status + api_status = session_response.get('status', '') + display_status = map_status(api_status) + + elapsed = time.time() - start_time + + if verbose: + print(f'\tPolling... Status: {display_status} | Elapsed: {int(elapsed)}s') + + # Print status change message + if display_status != previous_status: + click.echo(f'Status changed: {previous_status} → {display_status}') + previous_status = display_status + + # Exit watch mode if session is ready or terminated + if display_status == 'running': + click.secho('✓ Session is now running and ready to use!', fg='green') + break + elif display_status in ['stopped', 'terminated']: + click.secho(f'⚠ Session reached terminal state: {display_status}', fg='yellow') + break + + # Check timeout AFTER evaluating current status + if elapsed > max_wait_time_seconds: + click.secho( + f'Timeout: Session did not reach running state within {max_wait_time}. ' + f'Current status: {display_status}. Exiting watch mode.', + fg='red', + err=True + ) + break + + # Wait before next poll + time.sleep(watch_interval) + + # Fetch updated status for next iteration + 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 + ) + + # Transform and display response based on format + if output_format.lower() == 'json': + json_output = export_session_status_json(session_response) + outfile = f"{output_basename}.json" + with open(outfile, 'w') as f: + f.write(json_output) + click.echo(f'Session status saved to {outfile}') + + elif output_format.lower() == 'csv': + transformed_data = transform_session_response(session_response) + csv_output = export_session_status_csv(transformed_data) + outfile = f"{output_basename}.csv" + with open(outfile, 'w') as f: + f.write(csv_output) + click.echo(f'Session status saved to {outfile}') + + else: # stdout (default) + transformed_data = transform_session_response(session_response) + format_session_status_table(transformed_data, cloudos_url=cloudos_url) + + except ValueError as e: + # Handle validation errors (e.g., session not found) + 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 KeyboardInterrupt: + click.secho('\n⚠ Watch mode 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 retrieve session status. Please check your credentials.', fg='red', err=True) + else: + click.secho(f'Error: Failed to retrieve session status: {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 2996a1ae..b1686578 100644 --- a/cloudos_cli/interactive_session/interactive_session.py +++ b/cloudos_cli/interactive_session/interactive_session.py @@ -3,9 +3,16 @@ import pandas as pd import sys import re +import json +import time from datetime import datetime, timedelta, timezone from rich.table import Table from rich.console import Console +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry +from cloudos_cli.utils.requests import retry_requests_get + def validate_instance_type(instance_type, execution_platform='aws'): @@ -681,10 +688,39 @@ def parse_shutdown_duration(duration_str): delta = timedelta(hours=value) elif unit == 'd': delta = timedelta(days=value) - future_time = datetime.now(timezone.utc) + delta return future_time.isoformat().replace('+00:00', 'Z') +def parse_watch_timeout_duration(duration_str): + """Parse watch timeout duration string to seconds. + + Accepts formats: 30m, 2h, 1d, 30s + + Parameters + ---------- + duration_str : str + Duration string (e.g., "30m", "2h", "1d", "30s") + + Returns + ------- + int + Duration in seconds + """ + match = re.match(r'^(\d+)([smhd])$', duration_str.lower()) + if not match: + raise ValueError(f"Invalid duration format: {duration_str}. Use format like '30s', '30m', '2h', '1d'") + + value = int(match.group(1)) + unit = match.group(2) + + if unit == 's': + return value + elif unit == 'm': + return value * 60 + elif unit == 'h': + return value * 3600 + elif unit == 'd': + return value * 86400 def parse_data_file(data_file_str): """Parse data file format: either S3 or CloudOS dataset path. @@ -989,6 +1025,7 @@ def parse_link_path(link_path_str): } + def build_session_payload( name, backend, @@ -1260,4 +1297,580 @@ def format_session_creation_table(session_data, instance_type=None, storage_size console.print(table) console.print("\n[yellow]Note:[/yellow] Session provisioning typically takes 3-10 minutes.") - console.print("[cyan]Next steps:[/cyan] Use 'cloudos interactive-session list' to monitor status") + console.print("[cyan]Next steps:[/cyan] Use 'cloudos interactive-session status' to monitor status") + + +# ============================================================================ +# 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', + 'terminated': 'red', + 'provisioning': 'yellow', + 'scheduled': 'yellow', +} + +# Terminal states where watch mode should exit +TERMINAL_STATES = {'running', 'stopped', '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 + 'setup': 'setup', + 'initialising': 'initialising', + 'initializing': 'initialising', + 'scheduled': 'scheduled', + 'running': 'running', # Some endpoints may return 'running' + 'stopped': 'stopped', # Some endpoints may return 'stopped' + 'terminated': 'terminated', +} + +# Pre-running statuses (watch mode only valid for these) +PRE_RUNNING_STATUSES = {'setup', 'initialising', 'scheduled'} + + +def format_duration(seconds: int) -> str: + """Convert seconds to human-readable format. + + Examples: "2h 15m", "45m 30s", "30s" + """ + if not seconds or seconds <= 0: + return "Not started" + + seconds = int(seconds) + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + secs = seconds % 60 + + parts = [] + if hours > 0: + parts.append(f"{hours}h") + if minutes > 0: + parts.append(f"{minutes}m") + if secs > 0 or not parts: + parts.append(f"{secs}s") + + 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. + """ + return API_STATUS_MAPPING.get(api_status, api_status) + + +def format_timestamp(iso_timestamp: str = None) -> str: + """Convert ISO8601 timestamp to readable format. + + Example: "2026-03-13 10:30:00" + """ + if not iso_timestamp: + return "N/A" + + try: + dt = datetime.fromisoformat( + iso_timestamp.replace('Z', '+00:00') + ) + return dt.strftime("%Y-%m-%d %H:%M:%S") + except (ValueError, AttributeError): + return iso_timestamp + + +def format_cost(cost_value: float = None) -> str: + """Format cost as currency. + + Examples: "$12.50", "$0.00" + """ + if cost_value is None: + return "$0.00" + try: + return f"${float(cost_value):.2f}" + except (ValueError, TypeError): + return "$0.00" + + +def format_instance_type(instance_type: str, is_cost_saving: bool = False) -> str: + """Format instance type with spot indicator. + + Examples: "c5.xlarge", "c5.xlarge (spot)" + """ + if is_cost_saving: + return f"{instance_type} (spot)" + return instance_type + + +def validate_session_id(session_id: str) -> bool: + """Validate session ID format (24-character hex string).""" + if not session_id: + return False + return bool(re.match(r'^[a-f0-9]{24}$', session_id, re.IGNORECASE)) + + +class InteractiveSessionAPI: + """API client for interactive session operations.""" + + REQUEST_TIMEOUT = 30 # seconds + + def __init__(self, cloudos_url: str, apikey: str, verify_ssl: bool = True): + """Initialize API client. + + Parameters + ---------- + cloudos_url : str + Base CloudOS platform URL + apikey : str + API key for authentication + verify_ssl : bool + Whether to verify SSL certificates + """ + self.cloudos_url = cloudos_url.rstrip('/') + self.apikey = apikey + self.verify_ssl = verify_ssl + self.session = self._create_session() + + def _create_session(self) -> requests.Session: + """Create requests session with retry strategy.""" + session = requests.Session() + + # Configure retry strategy with exponential backoff + retry_strategy = Retry( + total=3, + backoff_factor=1, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=['GET'] + ) + + adapter = HTTPAdapter(max_retries=retry_strategy) + session.mount('http://', adapter) + session.mount('https://', adapter) + + return session + + def get_session_status(self, session_id: str, team_id: str) -> dict: + """Retrieve session status from API endpoint. + + GET /api/v2/interactive-sessions/{sessionId}?teamId={teamId} + + Parameters + ---------- + session_id : str + Session ID (24-character hex) + team_id : str + Team/workspace ID + + Returns + ------- + dict + Session status response + + Raises + ------ + PermissionError + If authentication fails (401, 403) + ValueError + If session not found (404) + RuntimeError + For other API errors + """ + url = f"{self.cloudos_url}/api/v2/interactive-sessions/{session_id}" + params = {'teamId': team_id} + headers = { + 'apikey': self.apikey, + 'Content-Type': 'application/json' + } + + try: + response = retry_requests_get( + url, + params=params, + headers=headers, + verify=self.verify_ssl + ) + + if response.status_code == 200: + return response.json() + elif response.status_code == 401: + raise PermissionError("Unauthorized: Invalid API key or credentials") + elif response.status_code == 403: + raise PermissionError("Forbidden: Insufficient permissions for this session") + elif response.status_code == 404: + raise ValueError( + f"Session not found. Verify session ID ({session_id}) " + f"and team ID ({team_id})" + ) + elif response.status_code == 500: + raise RuntimeError("Server error: Unable to retrieve session status") + else: + raise RuntimeError( + f"API error (HTTP {response.status_code}): {response.text}" + ) + + except requests.exceptions.Timeout: + raise RuntimeError(f"API request timeout after {self.REQUEST_TIMEOUT} seconds") + except requests.exceptions.ConnectionError as e: + raise RuntimeError(f"Failed to connect to CloudOS: {str(e)}") + + + +class OutputFormatter: + """Handles formatting output in different formats.""" + + @staticmethod + def format_stdout(session_data: dict, cloudos_url: str) -> None: + """Display session status as a rich table with color coding.""" + console = Console() + table = Table( + title="[bold cyan]Interactive Session Status[/bold cyan]", + show_header=True, + header_style="bold magenta" + ) + + table.add_column("Property", style="cyan", no_wrap=True) + table.add_column("Value", style="green") + + # Build session link and embed it in the name + session_id = session_data.get('id', 'N/A') + session_name = session_data.get('name', 'N/A') + + if cloudos_url and session_id != 'N/A': + base_url = cloudos_url.rstrip('/') + session_link = f"{base_url}/app/data-science/interactive-analysis/view/{session_id}/" + session_name_with_link = f"[link={session_link}]{session_name}[/link]" + else: + session_name_with_link = session_name + + table.add_row("Session ID", session_id) + table.add_row("Name", session_name_with_link) + + # Status with color coding + status = session_data.get('status', 'N/A') + status_color = STATUS_COLORS.get(status, 'white') + status_colored = f"[{status_color}]{status}[/{status_color}]" + table.add_row("Status", status_colored) + + # Add remaining fields + table.add_row("Backend", session_data.get('backend_type', 'N/A')) + table.add_row("Owner", session_data.get('owner', 'N/A')) + table.add_row("Project", session_data.get('project', 'N/A')) + table.add_row("Instance Type", session_data.get('instance_type', 'N/A')) + table.add_row("Storage", session_data.get('storage_size', 'N/A')) + table.add_row("Cost", session_data.get('cost', 'N/A')) + table.add_row("Runtime", session_data.get('runtime', 'N/A')) + table.add_row("Created At", session_data.get('created_at', 'N/A')) + + if session_data.get('last_saved'): + table.add_row("Last Saved", session_data.get('last_saved')) + + if session_data.get('auto_shutdown'): + table.add_row("Auto-Shutdown At", session_data.get('auto_shutdown')) + + if session_data.get('r_version'): + table.add_row("R Version", session_data.get('r_version')) + + console.print(table) + + @staticmethod + def format_json(raw_response: dict) -> str: + """Return raw API response as formatted JSON.""" + return json.dumps(raw_response, indent=2, default=str) + + @staticmethod + def format_csv(session_data: dict) -> str: + """Export as CSV with key fields.""" + csv_data = { + 'ID': session_data.get('id', ''), + 'Name': session_data.get('name', ''), + 'Status': session_data.get('status', ''), + 'Backend': session_data.get('backend_type', ''), + 'Instance': session_data.get('instance_type', ''), + 'Storage': session_data.get('storage_size', ''), + 'Cost': session_data.get('cost', ''), + 'Runtime': session_data.get('runtime', ''), + 'Created': session_data.get('created_at', ''), + } + + lines = [] + lines.append(','.join(csv_data.keys())) + lines.append(','.join(str(v) if v else '' for v in csv_data.values())) + + return '\n'.join(lines) + + +class WatchModeManager: + """Manages watch mode polling and display.""" + + def __init__(self, api_client: InteractiveSessionAPI, + session_id: str, team_id: str, interval: int = 10): + """Initialize watch mode manager. + + Parameters + ---------- + api_client : InteractiveSessionAPI + API client instance + session_id : str + Session ID to monitor + team_id : str + Team ID + interval : int + Polling interval in seconds (default: 10) + """ + self.api_client = api_client + self.session_id = session_id + self.team_id = team_id + self.interval = interval + self.start_time = time.time() + + def watch(self, verbose: bool = False) -> dict: + """Continuously poll session status until reaching terminal state. + + Terminal states: running, stopped, terminated + + Handles Ctrl+C gracefully. + """ + spinner_chars = ['◜', '◝', '◞', '◟'] + spinner_index = 0 + + try: + while True: + # Fetch status + response = self.api_client.get_session_status( + self.session_id, self.team_id + ) + + status = response.get('status', '') + elapsed = int(time.time() - self.start_time) + + # Display progress + spinner = spinner_chars[spinner_index % len(spinner_chars)] + + if verbose: + print( + f"\r{spinner} Status: {status:<12} | " + f"Elapsed: {elapsed}s", + end='', + flush=True + ) + + # Check if reached terminal state + if status in TERMINAL_STATES: + print() # New line after spinner + if status == 'running': + print( + "✓ Session is now running and ready to use!" + ) + else: + print( + f"⚠ Session reached terminal state: {status}" + ) + return response + + # Wait before next poll + spinner_index += 1 + time.sleep(self.interval) + + except KeyboardInterrupt: + print("\n⚠ Watch mode interrupted by user.") + raise + + def get_elapsed_time(self) -> str: + """Get formatted elapsed time.""" + elapsed = int(time.time() - self.start_time) + return format_duration(elapsed) + + +def transform_session_response(api_response: dict) -> dict: + """Transform raw API response to user-friendly display format.""" + session_id = api_response.get('_id', '') + name = api_response.get('name', 'N/A') + api_status = api_response.get('status', 'N/A') + status = map_status(api_status) # Map API status to display status + + # Map backend type + api_backend = api_response.get('interactiveSessionType', '') + backend_type = map_backend_type(api_backend) + + # Extract user info + user = api_response.get('user', {}) + owner = f"{user.get('name', '')} {user.get('surname', '')}".strip() + if not owner: + owner = user.get('email', 'N/A') + + # Extract project info + project = api_response.get('project', {}) + project_name = project.get('name', 'N/A') + + # Extract resource info + resources = api_response.get('resources', {}) + instance_type = resources.get('instanceType', 'N/A') + is_spot = resources.get('isCostSaving', False) + instance_display = format_instance_type(instance_type, is_spot) + + storage_size_gb = resources.get('storageSizeInGb', 'N/A') + storage_display = f"{storage_size_gb} GB" if isinstance(storage_size_gb, int) else 'N/A' + + # Cost and runtime + total_cost = api_response.get('totalCostInUsd', 0) + cost = format_cost(total_cost) + + total_runtime_seconds = api_response.get('totalRunningTimeInSeconds', 0) + runtime = format_duration(total_runtime_seconds) + + # Timestamps + created_at = format_timestamp(api_response.get('createdAt')) + last_saved = format_timestamp(api_response.get('lastSavedAt')) + + # Execution info + execution = api_response.get('execution', {}) + auto_shutdown = format_timestamp(execution.get('autoShutdownAtDate')) + + # R version (for RStudio) + r_version = api_response.get('rVersion') + + return { + 'id': session_id, + 'name': name, + 'status': status, + 'backend_type': backend_type, + 'owner': owner, + 'project': project_name, + 'instance_type': instance_display, + 'storage_size': storage_display, + 'cost': cost, + 'runtime': runtime, + 'created_at': created_at, + 'last_saved': last_saved if last_saved != 'N/A' else None, + 'auto_shutdown': auto_shutdown if auto_shutdown != 'N/A' else None, + 'r_version': r_version, + } + + +def export_session_status_json(session_data: dict, output_file: str = None) -> str: + """Export session status as JSON. + + Parameters + ---------- + session_data : dict + Raw API response + output_file : str, optional + Path to save JSON file. If None, returns JSON string. + + Returns + ------- + str + JSON formatted string + """ + json_str = json.dumps(session_data, indent=2, default=str) + + if output_file: + with open(output_file, 'w') as f: + f.write(json_str) + + return json_str + + +def export_session_status_csv(session_data: dict, output_file: str = None) -> str: + """Export session status as CSV. + + Parameters + ---------- + session_data : dict + Transformed session data (from transform_session_response) + output_file : str, optional + Path to save CSV file. If None, returns CSV string. + + Returns + ------- + str + CSV formatted string + """ + csv_str = OutputFormatter.format_csv(session_data) + + if output_file: + with open(output_file, 'w') as f: + f.write(csv_str) + + return csv_str + + +# ============================================================================ +# Wrapper Functions for CLI Integration +# ============================================================================ + +def get_interactive_session_status(cloudos_url: str, apikey: str, session_id: str, + team_id: str, verify_ssl: bool = True, + verbose: bool = False) -> dict: + """Wrapper function to fetch session status from API. + + Parameters + ---------- + cloudos_url : str + CloudOS platform URL + apikey : str + API key for authentication + session_id : str + Session ID (24-char hex) + team_id : str + Team/workspace ID + verify_ssl : bool + Whether to verify SSL certificates + verbose : bool + Whether to print verbose output + + Returns + ------- + dict + Raw API response + + Raises + ------ + ValueError + If session not found + PermissionError + If authentication fails + RuntimeError + For other API errors + """ + api_client = InteractiveSessionAPI( + cloudos_url=cloudos_url, + apikey=apikey, + verify_ssl=verify_ssl + ) + + return api_client.get_session_status(session_id, team_id) + + +def format_session_status_table(session_data: dict, cloudos_url: str = None) -> None: + """Wrapper function to display session status as a rich table. + + Parameters + ---------- + session_data : dict + Transformed session data (from transform_session_response) + cloudos_url : str, optional + CloudOS URL for creating links + """ + OutputFormatter.format_stdout(session_data, cloudos_url or '')