diff --git a/CHANGELOG.md b/CHANGELOG.md index 07f2df07..e5175d81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## lifebit-ai/cloudos-cli: changelog +## v2.92.1 (2026-06-12) + +### Fix: + +- Removes app sessions from the listing of interactive sessions + ## v2.91.0 (2026-05-28) ### Feat: diff --git a/cloudos_cli/_version.py b/cloudos_cli/_version.py index 1271f796..ed6f86f9 100644 --- a/cloudos_cli/_version.py +++ b/cloudos_cli/_version.py @@ -1 +1 @@ -__version__ = '2.91.0' +__version__ = '2.92.1' diff --git a/cloudos_cli/interactive_session/cli.py b/cloudos_cli/interactive_session/cli.py index a1c9b9b2..963aed1e 100644 --- a/cloudos_cli/interactive_session/cli.py +++ b/cloudos_cli/interactive_session/cli.py @@ -31,7 +31,8 @@ format_stop_success_output, poll_session_termination, build_resume_payload, - fetch_interactive_session_page + fetch_interactive_session_page, + APP_SESSION_TYPES ) from cloudos_cli.configure.configure import with_profile_config, CLOUDOS_URL from cloudos_cli.utils.cli_helpers import pass_debug_to_subcommands @@ -151,7 +152,7 @@ def list_sessions(ctx, raise ValueError('Please use a positive integer (>= 1) for the --page parameter') # Validate table columns if specified - valid_columns = {'id', 'name', 'status', 'type', 'instance', 'cost', 'owner', 'project', + valid_columns = {'id', 'name', 'status', 'type', 'instance', 'cost', 'owner', 'project', 'created_at', 'runtime', 'saved_at', 'resources', 'backend', 'version', 'spot', 'cost_limit', 'time_left'} selected_columns = table_columns @@ -191,10 +192,22 @@ def list_sessions(ctx, sessions = result.get('sessions', []) pagination_metadata = result.get('pagination_metadata', None) - # Create callback function for fetching additional pages - fetch_page = lambda page_num: fetch_interactive_session_page( - cl, workspace_id, page_num, limit, filter_status, filter_only_mine, archived, verify_ssl - ) + # Create callback function for fetching additional pages. + # App sessions must be filtered here too so that navigating to + # next/prev pages via interactive pagination never re-introduces them. + def fetch_page(page_num): + page_result = fetch_interactive_session_page( + cl, workspace_id, page_num, limit, filter_status, filter_only_mine, archived, verify_ssl + ) + page_result['sessions'] = [ + s for s in page_result.get('sessions', []) + if s.get('interactiveSessionType') not in APP_SESSION_TYPES + ] + return page_result + + # Filter out app sessions (awsCustomSession / azureCustomSession) — not supported via API key + # Client-side filter; the API has no type-exclusion parameter + sessions = [s for s in sessions if s.get('interactiveSessionType') not in APP_SESSION_TYPES] # Handle empty results if len(sessions) == 0: @@ -217,7 +230,7 @@ def list_sessions(ctx, with open(outfile, 'w') as o: o.write(json.dumps(sessions, indent=2)) print(f'\tInteractive session list collected with a total of {len(sessions)} sessions on this page.') - print(f'\tInteractive session list saved to {outfile}') + print(f'\tInteractive session list saved to {outfile}') else: raise ValueError('Unrecognised output format. Please use one of [stdout|csv|json]') diff --git a/cloudos_cli/interactive_session/interactive_session.py b/cloudos_cli/interactive_session/interactive_session.py index 3de7042a..53c2c8c9 100644 --- a/cloudos_cli/interactive_session/interactive_session.py +++ b/cloudos_cli/interactive_session/interactive_session.py @@ -57,6 +57,9 @@ def validate_instance_type(instance_type, execution_platform='aws'): return True, None +APP_SESSION_TYPES = frozenset({'awsCustomSession', 'azureCustomSession'}) + + def _map_session_type_to_friendly_name(session_type): """Map internal session type names to user-friendly display names. @@ -83,7 +86,9 @@ def _map_session_type_to_friendly_name(session_type): 'azureSpark': 'Spark', 'awsRStudio': 'RStudio', # Handle both capitalizations 'azureRStudio': 'RStudio', - 'awsWindowsSession': 'Windows' + 'awsWindowsSession': 'Windows', + 'awsCustomSession': 'App', + 'azureCustomSession': 'App' } return type_mapping.get(session_type, session_type) diff --git a/tests/test_interactive_session/test_list_sessions.py b/tests/test_interactive_session/test_list_sessions.py index 0fadf377..3252e3c1 100644 --- a/tests/test_interactive_session/test_list_sessions.py +++ b/tests/test_interactive_session/test_list_sessions.py @@ -245,6 +245,75 @@ def test_get_interactive_session_list_validation(self): cl.get_interactive_session_list('test_team', limit=150) +class TestAppSessionFilter: + """Tests for client-side app-session filtering.""" + + @patch('cloudos_cli.interactive_session.cli.Cloudos') + @patch('cloudos_cli.configure.configure.ConfigurationProfile.load_profile_and_validate_data') + def test_app_sessions_are_filtered_out(self, mock_config, mock_cloudos): + """list_sessions CLI must exclude awsCustomSession and azureCustomSession from its output.""" + runner = CliRunner() + mock_config.return_value = {} + mock_cloudos_instance = mock.MagicMock() + mock_cloudos.return_value = mock_cloudos_instance + mock_cloudos_instance.get_interactive_session_list.return_value = { + 'sessions': [ + {'_id': 'aaa', 'name': 'Jupyter', 'status': 'running', + 'interactiveSessionType': 'awsJupyterNotebook', + 'resources': {'instanceType': 'c5.xlarge'}, 'totalCostInUsd': 0.0}, + {'_id': 'bbb', 'name': 'MyApp', 'status': 'running', + 'interactiveSessionType': 'awsCustomSession', + 'resources': {'instanceType': 'c5.xlarge'}, 'totalCostInUsd': 0.0}, + {'_id': 'ccc', 'name': 'AzureApp', 'status': 'running', + 'interactiveSessionType': 'azureCustomSession', + 'resources': {'instanceType': 'c5.xlarge'}, 'totalCostInUsd': 0.0}, + ], + 'pagination_metadata': {'count': 3, 'page': 1, 'limit': 10, 'totalPages': 1} + } + + with runner.isolated_filesystem(): + result = runner.invoke(run_cloudos_cli, [ + 'interactive-session', 'list', + '--apikey', 'test_key', + '--cloudos-url', 'http://test.com', + '--workspace-id', 'test_team', + '--output-format', 'json', + ]) + + assert result.exit_code == 0, result.output + with open('interactive_sessions_list.json') as f: + saved_sessions = json.load(f) + + saved_ids = {s['_id'] for s in saved_sessions} + assert 'aaa' in saved_ids, "Regular session must be kept" + assert 'bbb' not in saved_ids, "awsCustomSession must be filtered out" + assert 'ccc' not in saved_ids, "azureCustomSession must be filtered out" + assert len(saved_sessions) == 1 + + def test_app_session_types_constant_contains_expected_values(self): + """APP_SESSION_TYPES must contain exactly the two app session type strings.""" + from cloudos_cli.interactive_session.interactive_session import APP_SESSION_TYPES + + assert len(APP_SESSION_TYPES) == 2, ( + f"APP_SESSION_TYPES must have exactly 2 entries, got {len(APP_SESSION_TYPES)}: {APP_SESSION_TYPES}" + ) + assert 'awsCustomSession' in APP_SESSION_TYPES + assert 'azureCustomSession' in APP_SESSION_TYPES + + def test_regular_sessions_not_filtered(self): + """Regular session types must pass through the filter unchanged.""" + from cloudos_cli.interactive_session.interactive_session import APP_SESSION_TYPES + + regular_types = [ + 'awsJupyterNotebook', 'azureJupyterNotebook', + 'awsVSCode', 'azureVSCode', + 'awsRstudio', 'azureRstudio', + 'awsSpark', 'awsWindowsSession', + ] + for t in regular_types: + assert t not in APP_SESSION_TYPES, f"{t} should not be filtered out" + + if __name__ == '__main__': pytest.main([__file__, '-v'])