cloudsnake 0.8.0__tar.gz → 0.10.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/PKG-INFO +28 -5
  2. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/README.md +25 -3
  3. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/pyproject.toml +4 -3
  4. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/cli/cli.py +4 -0
  5. cloudsnake-0.10.0/src/cloudsnake/cli/logs.py +74 -0
  6. cloudsnake-0.10.0/src/cloudsnake/cli/ssm.py +135 -0
  7. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/helpers.py +77 -1
  8. cloudsnake-0.10.0/src/cloudsnake/models/selector.py +9 -0
  9. cloudsnake-0.10.0/src/cloudsnake/sdk/cloudwatch.py +119 -0
  10. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/sdk/ssm_parameters.py +6 -4
  11. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/sdk/ssm_session.py +4 -4
  12. cloudsnake-0.10.0/src/cloudsnake/styles/tui.tcss +58 -0
  13. cloudsnake-0.10.0/src/cloudsnake/tui_v2.py +96 -0
  14. cloudsnake-0.8.0/src/cloudsnake/cli/ssm.py +0 -107
  15. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/CHANGELOG.md +0 -0
  16. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/LICENSE +0 -0
  17. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/__init__.py +0 -0
  18. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/__main__.py +0 -0
  19. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/cli/dto.py +0 -0
  20. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/cli/rds.py +0 -0
  21. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/cli/sso.py +0 -0
  22. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/logger.py +0 -0
  23. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/sdk/aws.py +0 -0
  24. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/sdk/ec2.py +0 -0
  25. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/sdk/rds_session.py +0 -0
  26. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/sdk/session.py +0 -0
  27. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/sdk/sso.py +0 -0
  28. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/sdk/sso_oidc.py +0 -0
  29. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/tui.py +0 -0
  30. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/src/cloudsnake/utils.py +0 -0
  31. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/tests/README.md +0 -0
  32. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/tests/__init__.py +0 -0
  33. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/tests/conftest.py +0 -0
  34. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/tests/test_cli.py +0 -0
  35. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/tests/test_helpers.py +0 -0
  36. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/tests/test_logger.py +0 -0
  37. {cloudsnake-0.8.0 → cloudsnake-0.10.0}/tests/test_rds.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudsnake
3
- Version: 0.8.0
3
+ Version: 0.10.0
4
4
  Summary: Some AWS CLI commands with a beautiful TUI
5
5
  License: GPL-3.0
6
6
  License-File: LICENSE
@@ -32,7 +32,8 @@ Requires-Dist: moto (>=5.0.9,<6.0.0)
32
32
  Requires-Dist: requests (>=2.32.5,<3.0.0)
33
33
  Requires-Dist: rich (>=14.2.0,<15.0.0)
34
34
  Requires-Dist: simple-term-menu (>=1.6.4,<2.0.0)
35
- Requires-Dist: typer (>=0.20.0,<0.21.0)
35
+ Requires-Dist: textual (>=7.2.0,<8.0.0)
36
+ Requires-Dist: typer (>=0.20,<0.22)
36
37
  Requires-Dist: typing-extensions (>=4.12.0,<5.0.0)
37
38
  Project-URL: Documentation, https://github.com/containerscrew/cloudsnake#readme
38
39
  Project-URL: Repository, https://github.com/containerscrew/cloudsnake
@@ -40,7 +41,7 @@ Description-Content-Type: text/markdown
40
41
 
41
42
  <p align="center" >
42
43
  <h3 align="center">cloudsnake 🐍</h3>
43
- <p align="center">Wrapping some useful AWS cli commands to operate AWS like EC2 SSM instance connection or RDS connection using IAM authentication</p>
44
+ <p align="center">Wrapping some useful AWS cli commands to operate some services like EC2, SSO and more</p>
44
45
  </p>
45
46
 
46
47
  ---
@@ -57,6 +58,7 @@ Description-Content-Type: text/markdown
57
58
  <img alt="PyPiP version" src="https://img.shields.io/pypi/v/cloudsnake">
58
59
  <img alt="License" src="https://img.shields.io/github/license/containerscrew/cloudsnake">
59
60
  <img alt="Linter" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json">
61
+ <img alt="PyPip downloads" src="https://img.shields.io/pypi/dm/cloudsnake">
60
62
  </p>
61
63
 
62
64
  ---
@@ -72,7 +74,7 @@ In your terminal, set the corresponding `AWS_PROFILE=MyProfile` if not using the
72
74
  ---
73
75
  <br><br>
74
76
  <p align="center">
75
- <img align="center" alt="SSM session" src="docs/img//cloudsnake-ssm-session.gif">
77
+ <img align="center" alt="SSM session" src="docs/img/cloudsnake-ssm-session.gif">
76
78
  <h3 align="center">SSM session</h3>
77
79
  </p>
78
80
 
@@ -100,7 +102,7 @@ cloudsnake --region us-east-1 ssm get-parameters # specify region
100
102
 
101
103
  <br><br>
102
104
  <p align="center">
103
- <img align="center" alt="SSO get-credentials" src="docs/img/cloudsnake-sso-get-credentials.png">
105
+ <img align="center" alt="SSO get-credentials" src="docs/img/cloudsnake-sso-credentials.png">
104
106
  <h3 align="center">SSO get-credentials</h3>
105
107
  </p>
106
108
 
@@ -112,6 +114,21 @@ cloudsnake --region eu-west-1 sso get-credentials --start-url https://myapp.awsa
112
114
  > This command will open your default browser. You will need to approve manually the authentication.
113
115
  > More use cases and examples for `cloudsnake sso get-credentials` can be found in [`docs/sso-get-credentials.md`](./docs/sso-get-credentials.md).
114
116
 
117
+ ---
118
+
119
+ <br><br>
120
+ <p align="center">
121
+ <img align="center" alt="Cloudwatch log stream" src="docs/img/cloudsnake-logs-stream.gif">
122
+ <h3 align="center">Cloudwatch log stream</h3>
123
+ </p>
124
+
125
+ ```shell
126
+ cloudsnake logs stream
127
+ cloudsnake logs stream --filter-pattern "ERROR"
128
+ ```
129
+ > [!NOTE]
130
+ > By the moment, `logs stream` only supports viewing logs from the moment you start the command. Future versions will include the ability to view historical logs.
131
+
115
132
  # Installation
116
133
 
117
134
  ## Using pipx (Recommended)
@@ -159,6 +176,12 @@ hint: See PEP 668 for the detailed specification.
159
176
  </pre>
160
177
  </details>
161
178
 
179
+ # Upgrade
180
+
181
+ ```bash
182
+ pipx upgrade cloudsnake
183
+ ```
184
+
162
185
  # Uninstall
163
186
 
164
187
  ```bash
@@ -1,6 +1,6 @@
1
1
  <p align="center" >
2
2
  <h3 align="center">cloudsnake 🐍</h3>
3
- <p align="center">Wrapping some useful AWS cli commands to operate AWS like EC2 SSM instance connection or RDS connection using IAM authentication</p>
3
+ <p align="center">Wrapping some useful AWS cli commands to operate some services like EC2, SSO and more</p>
4
4
  </p>
5
5
 
6
6
  ---
@@ -17,6 +17,7 @@
17
17
  <img alt="PyPiP version" src="https://img.shields.io/pypi/v/cloudsnake">
18
18
  <img alt="License" src="https://img.shields.io/github/license/containerscrew/cloudsnake">
19
19
  <img alt="Linter" src="https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json">
20
+ <img alt="PyPip downloads" src="https://img.shields.io/pypi/dm/cloudsnake">
20
21
  </p>
21
22
 
22
23
  ---
@@ -32,7 +33,7 @@ In your terminal, set the corresponding `AWS_PROFILE=MyProfile` if not using the
32
33
  ---
33
34
  <br><br>
34
35
  <p align="center">
35
- <img align="center" alt="SSM session" src="docs/img//cloudsnake-ssm-session.gif">
36
+ <img align="center" alt="SSM session" src="docs/img/cloudsnake-ssm-session.gif">
36
37
  <h3 align="center">SSM session</h3>
37
38
  </p>
38
39
 
@@ -60,7 +61,7 @@ cloudsnake --region us-east-1 ssm get-parameters # specify region
60
61
 
61
62
  <br><br>
62
63
  <p align="center">
63
- <img align="center" alt="SSO get-credentials" src="docs/img/cloudsnake-sso-get-credentials.png">
64
+ <img align="center" alt="SSO get-credentials" src="docs/img/cloudsnake-sso-credentials.png">
64
65
  <h3 align="center">SSO get-credentials</h3>
65
66
  </p>
66
67
 
@@ -72,6 +73,21 @@ cloudsnake --region eu-west-1 sso get-credentials --start-url https://myapp.awsa
72
73
  > This command will open your default browser. You will need to approve manually the authentication.
73
74
  > More use cases and examples for `cloudsnake sso get-credentials` can be found in [`docs/sso-get-credentials.md`](./docs/sso-get-credentials.md).
74
75
 
76
+ ---
77
+
78
+ <br><br>
79
+ <p align="center">
80
+ <img align="center" alt="Cloudwatch log stream" src="docs/img/cloudsnake-logs-stream.gif">
81
+ <h3 align="center">Cloudwatch log stream</h3>
82
+ </p>
83
+
84
+ ```shell
85
+ cloudsnake logs stream
86
+ cloudsnake logs stream --filter-pattern "ERROR"
87
+ ```
88
+ > [!NOTE]
89
+ > By the moment, `logs stream` only supports viewing logs from the moment you start the command. Future versions will include the ability to view historical logs.
90
+
75
91
  # Installation
76
92
 
77
93
  ## Using pipx (Recommended)
@@ -119,6 +135,12 @@ hint: See PEP 668 for the detailed specification.
119
135
  </pre>
120
136
  </details>
121
137
 
138
+ # Upgrade
139
+
140
+ ```bash
141
+ pipx upgrade cloudsnake
142
+ ```
143
+
122
144
  # Uninstall
123
145
 
124
146
  ```bash
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [tool.poetry]
6
6
  name = "cloudsnake"
7
- version = "0.8.0"
7
+ version = "0.10.0"
8
8
  description = 'Some AWS CLI commands with a beautiful TUI'
9
9
  authors = ["containerscrew <info@containerscrew.com>"]
10
10
  repository = "https://github.com/containerscrew/cloudsnake"
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Programming Language :: Python :: Implementation :: PyPy",
27
27
  ]
28
28
 
29
- include = [{ path = "tests", format = "sdist" }, { path = "CHANGELOG.md" }]
29
+ include = [{ path = "tests", format = "sdist" }, { path = "CHANGELOG.md" }, { path = "src/cloudsnake/styles/*.tcss"}]
30
30
 
31
31
  [tool.poetry.scripts]
32
32
  cloudsnake = 'cloudsnake.__main__:main'
@@ -45,7 +45,7 @@ boto3 = "^1.42.10"
45
45
  click = "<8.4.0"
46
46
  dacite = "^1.8.1"
47
47
  simple-term-menu = "^1.6.4"
48
- typer = "^0.20.0"
48
+ typer = ">=0.20,<0.22"
49
49
  typing-extensions = "^4.12.0"
50
50
  rich = "^14.2.0"
51
51
  jmespath = "^1.0.1"
@@ -53,3 +53,4 @@ moto = "^5.0.9"
53
53
  requests = "^2.32.5"
54
54
  boto3-stubs = "~=1.42.8"
55
55
  configparser = "^7.2.0"
56
+ textual = "^7.2.0"
@@ -2,7 +2,10 @@ import os
2
2
  import typer
3
3
  from typing import Optional
4
4
  from importlib.metadata import version
5
+
6
+
5
7
  from cloudsnake.cli.dto import Common, LoggingLevel
8
+ from cloudsnake.cli.logs import cw_logs
6
9
  from cloudsnake.cli.ssm import ssm
7
10
  from cloudsnake.cli.sso import sso
8
11
  from cloudsnake.sdk.session import SessionWrapper
@@ -26,6 +29,7 @@ app = typer.Typer(
26
29
 
27
30
  app.add_typer(ssm, name="ssm", help="Manage SSM operations")
28
31
  app.add_typer(sso, name="sso", help="Manage SSO operations")
32
+ app.add_typer(cw_logs, name="logs", help="Manage CloudWatch Logs operations")
29
33
 
30
34
 
31
35
  @app.callback()
@@ -0,0 +1,74 @@
1
+ import signal
2
+ import sys
3
+ import typer
4
+
5
+ from cloudsnake.helpers import (
6
+ log_groups_to_items,
7
+ normalize_log_group_arn_for_live_tail,
8
+ )
9
+ from cloudsnake.sdk.cloudwatch import CloudWatchLogsWrapper, print_colored_log
10
+ from cloudsnake.tui_v2 import SelectorApp
11
+
12
+ DEFAULT_LOG_FILTER = "[].{logGroupName: logGroupName, size: storedBytes, arn: arn}"
13
+
14
+
15
+ def signal_handler(sig, frame):
16
+ typer.secho("You pressed Ctrl+C! Exiting gracefully...", fg="bright_red")
17
+ sys.exit(0)
18
+
19
+
20
+ cw_logs = typer.Typer(
21
+ no_args_is_help=True,
22
+ pretty_exceptions_short=True,
23
+ pretty_exceptions_show_locals=False,
24
+ )
25
+
26
+
27
+ @cw_logs.command(
28
+ "stream", help="Live stream logs from a CloudWatch log group", no_args_is_help=False
29
+ )
30
+ def start_session(
31
+ ctx: typer.Context,
32
+ filter_pattern: str = typer.Option(
33
+ None,
34
+ "--filter-pattern",
35
+ "-f",
36
+ help="Filter pattern for log events",
37
+ ),
38
+ ):
39
+ signal.signal(signal.SIGINT, signal_handler)
40
+ cw = CloudWatchLogsWrapper(
41
+ session=ctx.obj.session,
42
+ profile=ctx.obj.profile,
43
+ region=ctx.obj.region,
44
+ query=DEFAULT_LOG_FILTER,
45
+ )
46
+ log_groups = cw.list_log_groups()
47
+ items = log_groups_to_items(log_groups)
48
+ app = SelectorApp(
49
+ items=items,
50
+ title=f"🚀 AWS Cloudwatch log groups — {ctx.obj.profile}",
51
+ placeholder="Type to filter log groups...",
52
+ )
53
+ selected_log_group = app.run()
54
+
55
+ if selected_log_group:
56
+ log_group_arn = next(
57
+ g["arn"] for g in log_groups if g["logGroupName"] == selected_log_group
58
+ )
59
+ typer.secho(
60
+ f"~> Starting live tail for log group: {selected_log_group}",
61
+ fg="bright_green",
62
+ )
63
+ try:
64
+ for event in cw.tail_log_group_live(
65
+ log_group_arn=normalize_log_group_arn_for_live_tail(log_group_arn),
66
+ filter_pattern=filter_pattern,
67
+ ):
68
+ print_colored_log(event, highlight_term=filter_pattern)
69
+ except KeyboardInterrupt:
70
+ typer.secho("\nStopped.", fg="bright_yellow")
71
+ else:
72
+ typer.secho("~> No log group selected", fg="bright_yellow")
73
+ raise typer.Exit(1)
74
+ return
@@ -0,0 +1,135 @@
1
+ import signal
2
+ import sys
3
+ from typing import Optional
4
+ from cloudsnake.helpers import ec2_targets_to_items, ssm_parameters_to_items
5
+ from cloudsnake.sdk.ssm_parameters import SSMParameterStoreWrapper
6
+ from cloudsnake.tui import EC2Tui, SSMTui
7
+ import typer
8
+
9
+ from cloudsnake.sdk.ec2 import EC2InstanceWrapper
10
+ from cloudsnake.sdk.ssm_session import SSMStartSessionWrapper
11
+ from cloudsnake.tui_v2 import SelectorApp
12
+
13
+ EC2_RUNNING_FILTER = "Name=instance-state-name,Values=running"
14
+
15
+ EC2_INSTANCE_SELECTOR_QUERY = "[].{TargetId: InstanceId, Name: Tags[?Key=='Name'].Value | [0], Ip: PrivateIpAddress}"
16
+
17
+ # TUI
18
+ ec2_tui = EC2Tui()
19
+ ssm_tui = SSMTui()
20
+
21
+
22
+ def signal_handler(sig, frame):
23
+ typer.secho("You pressed Ctrl+C! Exiting gracefully...", fg="bright_red")
24
+ sys.exit(0)
25
+
26
+
27
+ ssm = typer.Typer(
28
+ no_args_is_help=True,
29
+ pretty_exceptions_short=True,
30
+ pretty_exceptions_show_locals=False,
31
+ )
32
+
33
+
34
+ @ssm.command(
35
+ "start-session", help="Start session with the given target id", no_args_is_help=True
36
+ )
37
+ def start_session(
38
+ ctx: typer.Context,
39
+ target: Optional[str] = typer.Option(None, help="Target id of the instance"),
40
+ reason: Optional[str] = typer.Option(
41
+ "ssm-new-connection", help="Reason of the connection"
42
+ ),
43
+ with_instance_selector: Optional[bool] = typer.Option(
44
+ False,
45
+ "--with-instance-selector",
46
+ "-is",
47
+ help="Prompt a terminal menu and select the instance you want to connect. --target flag is no longer used",
48
+ ),
49
+ ):
50
+ signal.signal(signal.SIGINT, signal_handler)
51
+ ssm_wrapper = SSMStartSessionWrapper(
52
+ session=ctx.obj.session,
53
+ profile=ctx.obj.profile,
54
+ region=ctx.obj.region,
55
+ )
56
+
57
+ if with_instance_selector:
58
+ ec2 = EC2InstanceWrapper(
59
+ session=ctx.obj.session,
60
+ filters=EC2_RUNNING_FILTER,
61
+ query=EC2_INSTANCE_SELECTOR_QUERY,
62
+ profile=ctx.obj.profile,
63
+ region=ctx.obj.region,
64
+ )
65
+
66
+ # Fake data for testing
67
+ # instances = [
68
+ # {"TargetId": "i-003a434fb9c00f0f8", "Name": "WebServer-01", "Ip": "10.100.0.2"},
69
+ # {"TargetId": "i-0c7cca12079e449a5", "Name": "eks-instance-nodegroup-apps", "Ip": "10.100.0.3"},
70
+ # {"TargetId": "i-06ad6856a7ca778c6", "Name": "Database-Primary", "Ip": "10.100.0.4"},
71
+ # {"TargetId": "i-050ed4067698e7d26", "Name": "Cache-Server-01", "Ip": "10.100.0.5"},
72
+ # ]
73
+
74
+ instances = ec2.describe_ec2_instances()
75
+
76
+ if not instances:
77
+ typer.secho("~> No running instances found", fg="bright_yellow")
78
+ raise typer.Exit(1)
79
+
80
+ items = ec2_targets_to_items(instances)
81
+
82
+ app = SelectorApp(
83
+ items=items,
84
+ title=f"🚀 EC2 Instances — {ctx.obj.profile}",
85
+ placeholder="Type to filter by name or ID...",
86
+ )
87
+
88
+ result_id = app.run()
89
+
90
+ if result_id:
91
+ selected = next(item for item in instances if item["TargetId"] == result_id)
92
+ instance_id = selected["TargetId"]
93
+ return ssm_wrapper.start_session(instance_id, reason)
94
+ else:
95
+ typer.secho("~> No instance selected", fg="bright_yellow")
96
+ raise typer.Exit(1)
97
+
98
+ return ssm_wrapper.start_session(target, reason)
99
+
100
+
101
+ @ssm.command("get-parameters", help="Get secrets from parameter store")
102
+ def get_parameters(
103
+ ctx: typer.Context,
104
+ ):
105
+ signal.signal(signal.SIGINT, signal_handler)
106
+ ssm_wrapper = SSMParameterStoreWrapper(
107
+ session=ctx.obj.session,
108
+ profile=ctx.obj.profile,
109
+ region=ctx.obj.region,
110
+ )
111
+
112
+ parameters = ssm_wrapper.describe_parameters()
113
+
114
+ # fake_parameters= [
115
+ # {"Name": "/myapp/db_password", "Type": "SecureString"},
116
+ # {"Name": "/myapp/db_endpoint", "Type": "SecureString"},
117
+ # {"Name": "/myapp/redis_password", "Type": "SecureString"},
118
+ # {"Name": "/myapp/google_oauth", "Type": "SecureString"},
119
+ # ]
120
+
121
+ items = ssm_parameters_to_items(parameters)
122
+
123
+ if not parameters:
124
+ typer.echo("No parameters found.")
125
+ raise typer.Exit(1)
126
+
127
+ app = SelectorApp(
128
+ items=items,
129
+ title=f"🚀 SSM Parameter — {ctx.obj.profile}",
130
+ placeholder="Type to filter by name ...",
131
+ )
132
+
133
+ result_id = app.run()
134
+ parameter = ssm_wrapper.get_parameter_by_name(result_id)
135
+ typer.secho(f"~> {parameter}", fg="bright_green")
@@ -3,9 +3,10 @@ from datetime import datetime
3
3
  import os
4
4
  import signal
5
5
  import sys
6
-
7
6
  from typing_extensions import List, Dict
8
7
 
8
+ from cloudsnake.models.selector import SelectorItem
9
+
9
10
 
10
11
  def parse_filters(filters: str) -> List[Dict[str, List[str]]]:
11
12
  """Parse filters passed in commands like cloudsnake ec2 describe-instances --filters Name=instance-state-name,
@@ -91,3 +92,78 @@ def ensure_is_valid_dir(dirpath):
91
92
  """
92
93
  if not os.path.isdir(dirpath):
93
94
  raise NotADirectoryError(f"'{dirpath}' is not a directory.")
95
+
96
+
97
+ def ec2_targets_to_items(targets: list[dict]) -> list[SelectorItem]:
98
+ return [
99
+ SelectorItem(
100
+ id=t["TargetId"],
101
+ label=t.get("Name", t["TargetId"]),
102
+ meta=[t["Ip"]],
103
+ )
104
+ for t in targets
105
+ ]
106
+
107
+
108
+ def ssm_parameters_to_items(parameters: list[dict]) -> list[SelectorItem]:
109
+ return [
110
+ SelectorItem(
111
+ id=p["Name"],
112
+ label=p["Name"],
113
+ meta=[p.get("Type", "String")],
114
+ )
115
+ for p in parameters
116
+ ]
117
+
118
+
119
+ def log_groups_to_items(groups: list[dict]) -> list[SelectorItem]:
120
+ items: list[SelectorItem] = []
121
+
122
+ for g in groups:
123
+ name = g["logGroupName"]
124
+ stored_bytes = g.get("size")
125
+
126
+ meta = []
127
+ if stored_bytes is not None:
128
+ meta.append(format_bytes(stored_bytes))
129
+
130
+ items.append(
131
+ SelectorItem(
132
+ id=name,
133
+ label=name,
134
+ meta=meta,
135
+ )
136
+ )
137
+
138
+ return items
139
+
140
+
141
+ def format_bytes(bytes_size: int) -> str:
142
+ if bytes_size < 1024:
143
+ return f"{bytes_size} B"
144
+ elif bytes_size < 1024**2:
145
+ return f"{bytes_size / 1024:.2f} KB"
146
+ elif bytes_size < 1024**3:
147
+ return f"{bytes_size / 1024**2:.2f} MB"
148
+ else:
149
+ return f"{bytes_size / 1024**3:.2f} GB"
150
+
151
+
152
+ def normalize_log_group_arn_for_live_tail(arn: str) -> str:
153
+ """
154
+ CloudWatch returns log group ARNs ending with ':*'.
155
+ StartLiveTail requires the ARN WITHOUT ':*'.
156
+ """
157
+ if arn.endswith(":*"):
158
+ return arn[:-2]
159
+ return arn
160
+
161
+
162
+ # def s3_buckets_to_items(buckets: list[dict]) -> list[SelectorItem]:
163
+ # return [
164
+ # SelectorItem(
165
+ # id=b["Name"],
166
+ # label=b["Name"],
167
+ # )
168
+ # for b in buckets
169
+ # ]
@@ -0,0 +1,9 @@
1
+ from dataclasses import dataclass
2
+ from typing import Sequence
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class SelectorItem:
7
+ id: str
8
+ label: str
9
+ meta: Sequence[str] = ()
@@ -0,0 +1,119 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+
6
+ import jmespath
7
+ from typing import Any, Dict, Optional, Iterator
8
+
9
+ from rich.console import Console
10
+ from rich.text import Text
11
+
12
+ from cloudsnake.sdk.aws import App
13
+ from botocore.exceptions import ClientError
14
+
15
+ console = Console()
16
+
17
+
18
+ class CloudWatchLogsWrapper(App):
19
+ def __init__(
20
+ self,
21
+ filters: Optional[str] = None,
22
+ query: Optional[str] = None,
23
+ **kwargs,
24
+ ):
25
+ super().__init__(filters=filters, query=query, **kwargs)
26
+ self.log_groups: Dict[str, Any] = {}
27
+
28
+ @property
29
+ def client_name(self) -> str:
30
+ return "logs"
31
+
32
+ def list_log_groups(self) -> Any:
33
+ self.log_groups = {}
34
+ try:
35
+ paginator = self.client.get_paginator("describe_log_groups")
36
+
37
+ pagination_args = {}
38
+
39
+ if self.filters:
40
+ pagination_args["logGroupNamePrefix"] = self.filters
41
+
42
+ for page in paginator.paginate(**pagination_args):
43
+ for group in page.get("logGroups", []):
44
+ name = group.get("logGroupName")
45
+ if name:
46
+ self.log_groups[name] = group
47
+
48
+ result = list(self.log_groups.values())
49
+
50
+ return jmespath.search(self.query, result) if self.query else result
51
+
52
+ except ClientError as err:
53
+ self.log.error(
54
+ f"CloudWatch Logs describe_log_groups failed: "
55
+ f"{err.response['Error']['Code']} - "
56
+ f"{err.response['Error']['Message']}"
57
+ )
58
+ raise
59
+
60
+ def tail_log_group_live(
61
+ self,
62
+ log_group_arn: str,
63
+ filter_pattern: Optional[str] = None,
64
+ ) -> Iterator[dict]:
65
+ """
66
+ Live tail a CloudWatch log group using start_live_tail (push-based).
67
+ """
68
+ try:
69
+ kwargs = {
70
+ "logGroupIdentifiers": [log_group_arn],
71
+ }
72
+
73
+ if filter_pattern:
74
+ kwargs["logEventFilterPattern"] = filter_pattern
75
+
76
+ response = self.client.start_live_tail(**kwargs)
77
+ stream = response["responseStream"]
78
+
79
+ for event in stream:
80
+ if "sessionUpdate" in event:
81
+ for log_event in event["sessionUpdate"]["sessionResults"]:
82
+ yield log_event
83
+
84
+ except ClientError as err:
85
+ self.log.error(
86
+ f"CloudWatch Logs start_live_tail failed: "
87
+ f"{err.response['Error']['Code']} - "
88
+ f"{err.response['Error']['Message']}"
89
+ )
90
+ raise
91
+
92
+ # TODO: implement get_historical_logs
93
+ def get_historical_logs(self):
94
+ pass
95
+
96
+
97
+ def print_colored_log(event: dict, highlight_term: str | None = None) -> None:
98
+ stream = event.get("logStreamName", "unknown-stream")
99
+ message = event.get("message", "").rstrip()
100
+ timestamp = event.get("timestamp", 0)
101
+
102
+ time_str = time.strftime("%H:%M:%S", time.localtime(timestamp / 1000))
103
+
104
+ line = Text()
105
+ line.append(f"[{time_str}] ", style="yellow")
106
+ line.append(f"[{stream}] ", style="cyan")
107
+
108
+ if highlight_term:
109
+ pattern = re.compile(re.escape(highlight_term), re.IGNORECASE)
110
+ last = 0
111
+ for match in pattern.finditer(message):
112
+ line.append(message[last : match.start()])
113
+ line.append(match.group(), style="bold red on white")
114
+ last = match.end()
115
+ line.append(message[last:])
116
+ else:
117
+ line.append(message)
118
+
119
+ console.print(line)
@@ -14,7 +14,7 @@ class SSMParameterStoreWrapper(App):
14
14
  ):
15
15
  super().__init__(**kwargs)
16
16
  self.session_response_output = session_response_output
17
- self.parameters = {}
17
+ self.parameters = []
18
18
  self.log = logging.getLogger("cloudsnake.ssm")
19
19
 
20
20
  @property
@@ -24,10 +24,12 @@ class SSMParameterStoreWrapper(App):
24
24
  def describe_parameters(self):
25
25
  try:
26
26
  paginator = self.client.get_paginator("describe_parameters")
27
+ self.parameters = []
27
28
  for page in paginator.paginate():
28
- for page in paginator.paginate():
29
- self.parameters.update(page)
30
- return self.parameters["Parameters"]
29
+ if "Parameters" in page:
30
+ self.parameters.extend(page["Parameters"])
31
+ return self.parameters
32
+
31
33
  except ClientError as err:
32
34
  self.log.error(
33
35
  "Couldn't register device",
@@ -35,16 +35,16 @@ class SSMStartSessionWrapper(App):
35
35
  if shutil.which("session-manager-plugin") is None:
36
36
  raise FileNotFoundError(PLUGIN_NOT_FOUND_MSG)
37
37
 
38
- def start_session_response(self, target: str) -> Dict[str, Any]:
38
+ def start_session_response(self, target: str, reason: str) -> Dict[str, Any]:
39
39
  self.log.debug(f"ssm.start_session(Target={target})")
40
- res = self.client.start_session(Target=target, Reason="cloudsnake session")
40
+ res = self.client.start_session(Target=target, Reason=reason)
41
41
  self.session_response_output = res
42
42
  return res
43
43
 
44
- def start_session(self, target: str):
44
+ def start_session(self, target: str, reason: str = "cloudsnake session") -> int:
45
45
  self._ensure_plugin_installed()
46
46
  self.log.info(f"Starting SSM session for {target}")
47
- self.start_session_response(target)
47
+ self.start_session_response(target, reason)
48
48
 
49
49
  try:
50
50
  with ignore_user_entered_signals():
@@ -0,0 +1,58 @@
1
+ Screen {
2
+ background: #0f172a;
3
+ }
4
+
5
+ #main-container {
6
+ padding: 1 3;
7
+ height: 1fr;
8
+ background: #020617;
9
+ }
10
+
11
+ .main-title {
12
+ text-align: center;
13
+ color: #38bdf8;
14
+ text-style: bold;
15
+ margin-bottom: 1;
16
+ }
17
+
18
+ Input {
19
+ border: tall #334155;
20
+ background: #020617;
21
+ color: #e5e7eb;
22
+ padding: 0 1;
23
+ margin-bottom: 1;
24
+ }
25
+
26
+ Input:focus {
27
+ border: tall #a78bfa;
28
+ }
29
+
30
+ OptionList {
31
+ border: solid #1e293b;
32
+ background: #020617;
33
+ scrollbar-gutter: stable;
34
+ height: 1fr;
35
+ }
36
+
37
+ OptionList > .option {
38
+ color: #e5e7eb;
39
+ }
40
+
41
+ OptionList > .option--highlighted {
42
+ background: #1e293b;
43
+ color: #f8fafc;
44
+ text-style: bold;
45
+ }
46
+
47
+ OptionList:focus {
48
+ border: solid #38bdf8;
49
+ }
50
+
51
+ Notification {
52
+ background: #020617;
53
+ border: solid #a78bfa;
54
+ }
55
+
56
+ Notification.-warning {
57
+ border: solid #fbbf24;
58
+ }
@@ -0,0 +1,96 @@
1
+ from textual.app import App, ComposeResult
2
+ from textual.widgets import Header, Footer, OptionList, Input, Label
3
+ from textual.widgets.option_list import Option
4
+ from textual.containers import Vertical
5
+ from rich.text import Text
6
+
7
+ from cloudsnake.models.selector import SelectorItem
8
+
9
+
10
+ class SelectorApp(App[str]):
11
+ TITLE = "Cloudsnake Selector"
12
+ CSS_PATH = "styles/tui.tcss"
13
+
14
+ BINDINGS = [
15
+ ("ctrl+q", "hint_quit", "Quit hint"),
16
+ ("ctrl+c", "quit", "Quit"),
17
+ ("escape", "quit", "Cancel"),
18
+ ]
19
+
20
+ def __init__(
21
+ self,
22
+ items: list[SelectorItem],
23
+ title: str,
24
+ placeholder: str,
25
+ ):
26
+ super().__init__()
27
+ self.items = items
28
+ self.title = title
29
+ self.placeholder = placeholder
30
+
31
+ def compose(self) -> ComposeResult:
32
+ yield Header(show_clock=True)
33
+
34
+ with Vertical(id="main-container"):
35
+ yield Label(self.title, classes="main-title")
36
+ yield Input(
37
+ placeholder=self.placeholder,
38
+ id="search-box",
39
+ )
40
+ yield OptionList(id="item-list")
41
+
42
+ yield Footer()
43
+
44
+ def on_mount(self) -> None:
45
+ self.update_option_list(self.items)
46
+ self.query_one("#search-box").focus()
47
+
48
+ def create_rich_option(self, item: SelectorItem) -> Option:
49
+ text = Text.assemble(
50
+ (item.label, "bold #a78bfa"),
51
+ " ",
52
+ (f"({item.id})", "#94a3b8"),
53
+ )
54
+
55
+ for meta in item.meta:
56
+ text.append(" ")
57
+ text.append(f"[{meta}]", style="#fbbf24")
58
+
59
+ return Option(text, id=item.id)
60
+
61
+ def update_option_list(self, items: list[SelectorItem]) -> None:
62
+ option_list = self.query_one("#item-list", OptionList)
63
+ option_list.clear_options()
64
+ option_list.add_options([self.create_rich_option(item) for item in items])
65
+
66
+ def on_input_changed(self, event: Input.Changed) -> None:
67
+ term = event.value.lower()
68
+
69
+ if not term:
70
+ filtered = self.items
71
+ else:
72
+ filtered = [
73
+ item
74
+ for item in self.items
75
+ if term in item.label.lower()
76
+ or term in item.id.lower()
77
+ or any(term in m.lower() for m in item.meta)
78
+ ]
79
+
80
+ self.update_option_list(filtered)
81
+
82
+ def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
83
+ self.exit(event.option_id)
84
+
85
+ def on_input_submitted(self, event: Input.Submitted) -> None:
86
+ option_list = self.query_one("#item-list", OptionList)
87
+ if option_list.option_count > 0:
88
+ option_list.focus()
89
+
90
+ def action_hint_quit(self) -> None:
91
+ self.notify(
92
+ "Use Ctrl+C to exit",
93
+ title="Quit disabled",
94
+ severity="warning",
95
+ timeout=2.5,
96
+ )
@@ -1,107 +0,0 @@
1
- import signal
2
- import sys
3
- from typing import Optional
4
- from cloudsnake.cli.dto import OutputMode
5
- from cloudsnake.sdk.ssm_parameters import SSMParameterStoreWrapper
6
- from cloudsnake.tui import EC2Tui, SSMTui
7
- import typer
8
-
9
- from cloudsnake.sdk.ec2 import EC2InstanceWrapper
10
- from cloudsnake.sdk.ssm_session import SSMStartSessionWrapper
11
-
12
- EC2_RUNNING_FILTER = "Name=instance-state-name,Values=running"
13
-
14
- EC2_INSTANCE_SELECTOR_QUERY = (
15
- "[].{TargetId: InstanceId, Name: Tags[?Key=='Name'].Value | [0]}"
16
- )
17
-
18
- # TUI
19
- ec2_tui = EC2Tui()
20
- ssm_tui = SSMTui()
21
-
22
-
23
- def signal_handler(sig, frame):
24
- typer.secho("You pressed Ctrl+C! Exiting gracefully...", fg="bright_red")
25
- sys.exit(0)
26
-
27
-
28
- ssm = typer.Typer(
29
- no_args_is_help=True,
30
- pretty_exceptions_short=True,
31
- pretty_exceptions_show_locals=False,
32
- )
33
-
34
-
35
- @ssm.command(
36
- "start-session", help="Start session with the given target id", no_args_is_help=True
37
- )
38
- def start_session(
39
- ctx: typer.Context,
40
- target: Optional[str] = typer.Option(None, help="Target id of the instance"),
41
- reason: Optional[str] = typer.Option(
42
- "ssm-new-connection", help="Reason of the connection"
43
- ),
44
- with_instance_selector: Optional[bool] = typer.Option(
45
- False,
46
- "--with-instance-selector",
47
- "-is",
48
- help="Prompt a terminal menu and select the instance you want to connect. --target flag is no longer used",
49
- ),
50
- ):
51
- signal.signal(signal.SIGINT, signal_handler)
52
- ssm = SSMStartSessionWrapper(
53
- session=ctx.obj.session,
54
- profile=ctx.obj.profile,
55
- region=ctx.obj.region,
56
- )
57
-
58
- if with_instance_selector:
59
- ec2 = EC2InstanceWrapper(
60
- session=ctx.obj.session,
61
- filters=EC2_RUNNING_FILTER,
62
- query=EC2_INSTANCE_SELECTOR_QUERY,
63
- profile=ctx.obj.profile,
64
- region=ctx.obj.region,
65
- )
66
-
67
- instances = ec2.describe_ec2_instances()
68
- if not instances:
69
- typer.echo("No running instances found.")
70
- raise typer.Exit(1)
71
-
72
- instance_name = ec2_tui.interactive_menu(
73
- instances, title="Select the EC2 you want to connect"
74
- )
75
- instance_id = ec2_tui.get_target_id_by_name(instances, instance_name)
76
-
77
- return ssm.start_session(instance_id)
78
-
79
- return ssm.start_session(target)
80
-
81
-
82
- @ssm.command("get-parameters", help="Get secrets from parameter store")
83
- def get_parameters(
84
- ctx: typer.Context,
85
- output: Optional[OutputMode] = typer.Option(
86
- OutputMode.json, "--output", "-o", help="Output mode", case_sensitive=True
87
- ),
88
- colored: Optional[bool] = typer.Option(
89
- True, "--no-color", "-nc", help="Output with highlights."
90
- ),
91
- ):
92
- signal.signal(signal.SIGINT, signal_handler)
93
- ssm = SSMParameterStoreWrapper(
94
- session=ctx.obj.session,
95
- profile=ctx.obj.profile,
96
- region=ctx.obj.region,
97
- )
98
-
99
- parameters = ssm.describe_parameters()
100
-
101
- if not parameters:
102
- typer.echo("No parameters found.")
103
- raise typer.Exit(1)
104
-
105
- parameter_name = ssm_tui.interactive_menu(parameters)
106
- parameter = ssm.get_parameter_by_name(parameter_name)
107
- typer.secho(parameter, fg="bright_green")
File without changes
File without changes
File without changes