Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions paasta_tools/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ def add_subparser(command, subparsers):
"stop": "start_stop_restart",
"restart": "start_stop_restart",
"status": "status",
"tui": "tui",
"validate": "validate",
"wait-for-deployment": "wait_for_deployment",
}
Expand Down
41 changes: 3 additions & 38 deletions paasta_tools/cli/cmds/mark_for_deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,6 @@
from sticht import state_machine
from sticht.rollbacks.base import RollbackSlackDeploymentProcess
from sticht.rollbacks.slo import SLOWatcher
from sticht.rollbacks.types import MetricWatcher
from sticht.rollbacks.types import SplunkAuth

from paasta_tools import remote_git
Expand Down Expand Up @@ -88,7 +87,6 @@
from paasta_tools.utils import _log
from paasta_tools.utils import _log_audit
from paasta_tools.utils import format_tag
from paasta_tools.utils import get_files_of_type_in_dir
from paasta_tools.utils import get_git_url
from paasta_tools.utils import get_paasta_tag_from_deploy_group
from paasta_tools.utils import get_rollback_tags_for_sha
Expand Down Expand Up @@ -379,24 +377,6 @@ def can_user_deploy_service(deploy_info: Dict[str, Any], service: str) -> bool:
return True


def can_run_metric_watcher_threads(
service: str,
soa_dir: str,
) -> bool:
"""
Cannot run slo and metric watcher threads together for now.
SLO Watcher Threads take precedence over metric watcher threads.
Metric Watcher Threads can run if there are no SLOs available.
"""
slo_files = get_files_of_type_in_dir(
file_type="slo", service=service, soa_dir=soa_dir
)
rollback_files = get_files_of_type_in_dir(
file_type="rollback", service=service, soa_dir=soa_dir
)
return bool(not slo_files and rollback_files)


def report_waiting_aborted(service: str, deploy_group: str) -> None:
print(
PaastaColors.red(
Expand Down Expand Up @@ -715,13 +695,8 @@ def __init__(
self.progress = Progress()
self.last_action = None
self.slo_watchers: List[SLOWatcher] = []
self.metric_watchers: List[MetricWatcher] = []
self.start_slo_watcher_threads(self.service, self.soa_dir)

# TODO: Allow both metric and slo watcher threads to run together in the future
if can_run_metric_watcher_threads(service=self.service, soa_dir=self.soa_dir):
self.start_metric_watcher_threads(self.service, self.soa_dir)

# Initialize Slack threads and send the first message
super().__init__()
self.print_who_is_running_this()
Expand Down Expand Up @@ -1368,19 +1343,9 @@ def get_signalfx_api_token(self) -> str:
)

def get_splunk_api_token(self) -> SplunkAuth:
auth_token = os.environ["SPLUNK_MFD_TOKEN"]
auth_data = (
load_system_paasta_config()
.get_monitoring_config()
.get("splunk_mfd_authentication")
)

return SplunkAuth(
host=auth_data["host"],
port=auth_data["port"],
username=auth_data["username"],
password=auth_token,
)
# Splunk-based autorollbacks have been removed (PAASTA-18858).
# This stub satisfies the abstract method in sticht until it's removed there.
return SplunkAuth(host="", port=0, username="", password="")

def get_button_text(self, button: str, is_active: bool) -> str:
# Button text max length 75 characters
Expand Down
33 changes: 33 additions & 0 deletions paasta_tools/cli/cmds/tui/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import argparse

from paasta_tools.cli.utils import lazy_choices_completer
from paasta_tools.utils import list_clusters


def add_subparser(subparsers: argparse._SubParsersAction) -> None:
tui_parser = subparsers.add_parser(
"tui",
help="Interactive terminal UI for PaaSTA (k9s-style)",
description="Browse clusters, services, instances, and pods interactively.",
)
tui_parser.add_argument(
"-c",
"--cluster",
help="Start directly in this cluster",
default=None,
).completer = lazy_choices_completer(list_clusters)
tui_parser.set_defaults(command=paasta_tui)


def paasta_tui(args: argparse.Namespace, **kwargs: object) -> int:
try:
from paasta_tools.cli.cmds.tui.app import PaastaApp
except ImportError:
print(
"The TUI requires the 'textual' package.\n"
"Install with: pip install 'paasta-tools[tui]'"
)
return 1
app = PaastaApp()
app.run()
return 0
57 changes: 57 additions & 0 deletions paasta_tools/cli/cmds/tui/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from textual.app import App
from textual.app import ComposeResult
from textual.binding import Binding

from paasta_tools.cli.cmds.tui.data.fetcher import PaastaDataFetcher
from paasta_tools.cli.cmds.tui.screens.services import ServicesScreen
from paasta_tools.cli.cmds.tui.widgets.breadcrumb import Breadcrumb


class PaastaApp(App):
TITLE = "PaaSTA TUI"
theme = "textual-dark"
CSS = """
Screen {
layout: vertical;
}
#filter-input {
dock: top;
height: 1;
border: none;
margin: 0;
padding: 0 1;
}
#filter-status {
dock: top;
height: 1;
color: $text-muted;
padding: 0 1;
}
LoadingIndicator {
height: 1fr;
}
FilterableTable {
height: 1fr;
border: round $primary;
padding: 0 1;
}
"""

BINDINGS = [
Binding("q", "quit", "Quit"),
]

def __init__(self, fetcher: PaastaDataFetcher | None = None) -> None:
super().__init__()
self._fetcher = fetcher or PaastaDataFetcher()

def compose(self) -> ComposeResult:
yield Breadcrumb()

@property
def breadcrumb(self) -> Breadcrumb:
return self.query_one(Breadcrumb)

def on_mount(self) -> None:
self.breadcrumb.push("Services")
self.push_screen(ServicesScreen(self._fetcher))
Empty file.
196 changes: 196 additions & 0 deletions paasta_tools/cli/cmds/tui/data/fetcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import os
from concurrent.futures import ThreadPoolExecutor

from paasta_tools.api.client import get_paasta_oapi_client
from paasta_tools.cli.cmds.tui.data.models import ClusterInfo
from paasta_tools.cli.cmds.tui.data.models import InstanceInfo
from paasta_tools.cli.cmds.tui.data.models import ServiceInfo
from paasta_tools.monitoring_tools import get_runbook
from paasta_tools.monitoring_tools import get_team
from paasta_tools.utils import DEFAULT_SOA_DIR
from paasta_tools.utils import SystemPaastaConfig
from paasta_tools.utils import list_clusters as paasta_list_clusters
from paasta_tools.utils import list_services as paasta_list_services
from paasta_tools.utils import load_system_paasta_config
from paasta_tools.utils import read_service_configuration


class PaastaDataFetcher:
def __init__(self, system_config: SystemPaastaConfig | None = None) -> None:
self._system_config = system_config

@property
def system_config(self) -> SystemPaastaConfig:
if self._system_config is None:
self._system_config = load_system_paasta_config()
return self._system_config

def get_all_services(self) -> list[ServiceInfo]:
soa_dir = DEFAULT_SOA_DIR
names = [
name
for name in paasta_list_services(soa_dir=soa_dir)
if not name.startswith((".", "_"))
and os.path.isdir(os.path.join(soa_dir, name))
]
results = []
for name in sorted(names):
try:
config = read_service_configuration(name, soa_dir)
except Exception:
config = {}
results.append(
ServiceInfo(
name=name,
description=config.get("description", ""),
team=get_team(service=name, overrides={}, soa_dir=soa_dir),
runbook=get_runbook(service=name, overrides={}, soa_dir=soa_dir),
external_link=config.get("external_link", ""),
git_repo=config.get("git_url", ""),
)
)
return results

def get_clusters_for_service(self, service: str) -> list[ClusterInfo]:
endpoints = self.system_config.get_api_endpoints()
service_clusters = paasta_list_clusters(service=service)
return [
ClusterInfo(name=cluster, api_endpoint=endpoints.get(cluster, ""))
for cluster in sorted(service_clusters)
if cluster in endpoints
]

def _get_client_for_cluster(self, cluster: str):
endpoints = self.system_config.get_api_endpoints()
eks_cluster = f"eks-{cluster}"
if eks_cluster in endpoints:
client = get_paasta_oapi_client(
cluster=eks_cluster, system_paasta_config=self.system_config
)
if client is not None:
return client
return get_paasta_oapi_client(
cluster=cluster, system_paasta_config=self.system_config
)

def list_instance_names(self, cluster: str, service: str) -> list[str]:
client = self._get_client_for_cluster(cluster)
if client is None:
return []
response = client.service.list_instances(service=service)
return sorted(response.get("instances", []))

def get_instances(self, cluster: str, service: str) -> list[InstanceInfo]:
client = self._get_client_for_cluster(cluster)
if client is None:
return []
response = client.service.list_instances(service=service)
instance_names: list[str] = response.get("instances", [])
with ThreadPoolExecutor(max_workers=20) as pool:
futures = {
name: pool.submit(self._get_instance_info, client, service, name)
for name in sorted(instance_names)
}
return [
result
for name in sorted(instance_names)
if (result := futures[name].result()) is not None
]

_INSTANCE_TYPES = (
"kubernetes_v2",
"tron",
"flink",
"flinkeks",
"kafkacluster",
"cassandracluster",
"cassandraclustereks",
"adhoc",
)

def _detect_instance_type(self, status) -> str:
for itype in self._INSTANCE_TYPES:
if status.get(itype) is not None:
return itype
return "unknown"

def _get_instance_info(
self, client, service: str, instance: str
) -> InstanceInfo | None:
try:
status = client.service.status_instance(
service=service, instance=instance, verbose=1, new=True
)
except Exception:
return None
instance_type = self._detect_instance_type(status)
tron = status.get("tron")
if tron is not None:
action_state = tron.get("action_state", "unknown")
if len(action_state) > 25:
action_state = action_state[:25] + "..."
is_ok = action_state in ("succeeded", "running", "waiting", "scheduled")
return InstanceInfo(
name=instance,
instance_type="tron",
state=action_state.capitalize(),
ready=1 if action_state == "succeeded" else 0,
desired=1,
git_sha="",
num_versions=0,
error="" if is_ok else action_state,
)
k8s = status.get("kubernetes_v2")
if k8s is None:
raw_state = status.get("desired_state", "Unknown")
if len(raw_state) > 25:
raw_state = raw_state[:25] + "..."
return InstanceInfo(
name=instance,
instance_type=instance_type,
state=raw_state,
ready=0,
desired=0,
git_sha=(status.get("git_sha") or "")[:8],
num_versions=0,
error="",
)
desired_state = k8s.get("desired_state", "Unknown")
desired_instances = k8s.get("desired_instances", 0)
versions = k8s.get("versions", [])
ready_replicas = sum(v.get("ready_replicas", 0) for v in versions)
total_replicas = sum(v.get("replicas", 0) for v in versions)
git_sha = ""
if versions:
git_sha = versions[0].get("git_sha", "")[:8]
if desired_state == "stop":
if total_replicas == 0:
state = "Stopped"
else:
state = "Stopping"
elif len(versions) > 1:
state = "Bouncing"
elif len(versions) == 1:
if ready_replicas >= desired_instances and desired_instances > 0:
state = "Running"
elif ready_replicas > 0:
state = "Launching replicas"
else:
state = "Starting"
elif desired_instances == 0:
state = "Stopped"
else:
state = "Starting"
error = k8s.get("error_message", "") or ""
if len(error) > 50:
error = error[:50] + "..."
return InstanceInfo(
name=instance,
instance_type=instance_type,
state=state,
ready=ready_replicas,
desired=desired_instances,
git_sha=git_sha,
num_versions=len(versions),
error=error,
)
Loading
Loading