Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
2759ad2
feat: add rp catalog for gpu and cpu pricing and stock
KAJdev Oct 19, 2026
15a69b4
test: cover rp catalog and shared compute resolution
KAJdev Oct 19, 2026
17f26d7
feat: add rp volume commands for network and global volumes
KAJdev Oct 19, 2026
472ecec
Merge remote-tracking branch 'origin/zeke/con-1118-rp-catalog' into z…
KAJdev Oct 19, 2026
232e7cb
feat: add rp pod lifecycle commands
KAJdev Oct 19, 2026
0e98c3b
fix: keep global volume ids whole in rp volume list
KAJdev Oct 19, 2026
ed08599
fix: keep table ids and values whole on narrow terminals
KAJdev Oct 19, 2026
08f88c8
Merge remote-tracking branch 'origin/zeke/con-1118-rp-catalog' into z…
KAJdev Oct 19, 2026
1173258
fix: wait for the pod container to start, not just its desired status
KAJdev Oct 19, 2026
6ef070e
Merge remote-tracking branch 'origin/zeke/con-1118-rp-catalog' into z…
KAJdev Oct 19, 2026
619e23b
refactor: consolidate CLI resolution and rendering helpers
KAJdev Oct 19, 2026
1bab5e8
Merge remote-tracking branch 'origin/zeke/con-1118-rp-catalog' into z…
KAJdev Oct 19, 2026
139713a
refactor: simplify volume commands and shared API integration
KAJdev Oct 19, 2026
bdcd231
Merge remote-tracking branch 'origin/zeke/con-1118-rp-catalog' into z…
KAJdev Oct 19, 2026
bc5e659
style: remove redundant volume docstrings
KAJdev Oct 19, 2026
b33a271
refactor: share SDK log streaming across resource commands
KAJdev Oct 19, 2026
574eb91
Merge remote-tracking branch 'origin/zeke/con-1118-rp-catalog' into z…
KAJdev Oct 19, 2026
046f868
Merge remote-tracking branch 'origin/zeke/con-1118-rp-catalog' into z…
KAJdev Oct 19, 2026
c4489ab
refactor: simplify pod commands and share log streaming
KAJdev Oct 19, 2026
329f7d5
fix: clarify log stream completion handling
KAJdev Oct 19, 2026
5dbc295
Merge remote-tracking branch 'origin/zeke/con-1118-rp-catalog' into z…
KAJdev Oct 19, 2026
fcf3305
chore: integrate final catalog fixes
KAJdev Oct 19, 2026
9880c1d
fix: fit resource picker labels to terminal width
KAJdev Oct 20, 2026
f65a52d
chore: integrate shared picker width fix
KAJdev Oct 20, 2026
b9fec28
chore: integrate shared picker width fix
KAJdev Oct 20, 2026
6d0518d
fix: read pod sync SSH keys through REST
KAJdev Oct 20, 2026
eb6a82d
refactor: scope catalog shared helpers to hardware and output
KAJdev Oct 20, 2026
6a22c8f
chore: align volume with catalog layer
KAJdev Oct 20, 2026
31c8f8d
chore: stack pod commands on volume
KAJdev Oct 20, 2026
01097e7
fix: harden pod readiness and log streaming
KAJdev Oct 20, 2026
f03757b
fix: honor catalog cloud and cuda constraints
KAJdev Oct 20, 2026
8d66981
chore: sync catalog review fixes
KAJdev Oct 20, 2026
454d110
chore: sync catalog review fixes through volumes
KAJdev Oct 20, 2026
83e0415
fix: reconcile catalog completion with main signal cleanup
KAJdev Oct 22, 2026
4ffed7f
chore: sync catalog integration into volume commands
KAJdev Oct 23, 2026
b416d94
chore: sync volume integration into pod commands
KAJdev Oct 23, 2026
78d57d2
fix: sync volume stack with catalog
KAJdev Oct 27, 2026
b96f6cc
fix: sync pod stack with volume
KAJdev Oct 27, 2026
4105308
fix: sync catalog stack with main
KAJdev Oct 27, 2026
7c50d23
fix: harden pod logs and lifecycle commands
KAJdev Oct 28, 2026
2ddcfca
fix: validate catalog flags and preserve CPU price precision
KAJdev Oct 28, 2026
237285d
test: set the default asyncio fixture loop scope
KAJdev Oct 28, 2026
3291ca4
fix: set async fixture loop scope
KAJdev Oct 28, 2026
7cfe75c
feat: add rp volume commands for network and global volumes
KAJdev Oct 19, 2026
870c747
fix: keep global volume ids whole in rp volume list
KAJdev Oct 19, 2026
b6d8a4a
refactor: simplify volume commands and shared API integration
KAJdev Oct 19, 2026
eaac811
style: remove redundant volume docstrings
KAJdev Oct 19, 2026
024b14f
fix: sync pod commands with volume base
KAJdev Oct 28, 2026
c70d3a6
feat: add rp volume commands for network and global volumes
KAJdev Oct 19, 2026
53240a7
fix: keep global volume ids whole in rp volume list
KAJdev Oct 19, 2026
5c900f9
refactor: simplify volume commands and shared API integration
KAJdev Oct 19, 2026
4f77f44
style: remove redundant volume docstrings
KAJdev Oct 19, 2026
4a8221e
fix: reconcile pod history with volume base
KAJdev Oct 28, 2026
920be30
fix: reconcile pod commands with main
KAJdev Oct 28, 2026
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
62 changes: 46 additions & 16 deletions runpod/apps/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@
streams and finite snapshots use the host api, which supports both.
"""

import asyncio
import json
import os
from typing import Any, AsyncIterator, Dict, List, Optional
from typing import Any, AsyncIterator, Callable, Dict, List, Optional

import aiohttp

Expand Down Expand Up @@ -46,15 +47,21 @@ async def stream_pod_logs(
pod_id: str,
*,
endpoint_id: Optional[str] = None,
log_type: str = "all",
log_type: Optional[str] = "all",
tail: int = 100,
since: Optional[str] = None,
last_event_id: Optional[str] = None,
on_event_id: Optional[Callable[[str], None]] = None,
idle_timeout: Optional[float] = None,
) -> AsyncIterator[Dict[str, Any]]:
"""follow a pod's logs as they arrive.

yields {"source": "container"|"system", "line": str, "ts": str}
parsed from the sse stream. pass endpoint_id for serverless workers.
ends when the server closes the stream or the caller breaks out.
log_type=None selects both sources through rest. last_event_id resumes
a stream; on_event_id receives the cursor of each delivered event.
idle_timeout ends a stream after that many seconds without content,
measured only after the response headers have been received.
"""
params: Dict[str, Any] = {"tail": str(tail)}
if log_type == "all":
Expand All @@ -69,28 +76,51 @@ async def stream_pod_logs(
f"/workers/{_path_segment(pod_id)}/logs"
)
url = _build_url(path)
params["source"] = log_type
if log_type:
params["source"] = log_type
if since:
params["since"] = since
headers = _headers()
if last_event_id:
headers["Last-Event-ID"] = last_event_id

client_timeout = aiohttp.ClientTimeout(
total=STREAM_TIMEOUT_SECONDS, sock_read=60
)
client_timeout = aiohttp.ClientTimeout(total=STREAM_TIMEOUT_SECONDS, sock_read=60)
async with aiohttp.ClientSession(timeout=client_timeout) as session:
async with session.get(
url, params=params, headers=_headers()
) as resp:
async with session.get(url, params=params, headers=headers) as resp:
resp.raise_for_status()
async for raw in resp.content:
line = raw.decode("utf-8", "replace").strip()
# sse frames: "data: {...}"; comments (heartbeats) start
# with ":"
if not line.startswith("data:"):
event_id = None
data = []
content = resp.content.__aiter__()
while True:
try:
raw = await asyncio.wait_for(anext(content), timeout=idle_timeout)
except StopAsyncIteration:
return
except asyncio.TimeoutError:
if idle_timeout is None:
raise
return
line = raw.decode("utf-8", "replace").rstrip("\r\n")
if line:
field, _, value = line.partition(":")
if value.startswith(" "):
value = value[1:]
if field == "id" and "\x00" not in value:
event_id = value
elif field == "data":
data.append(value)
continue
if not data:
continue
try:
yield json.loads(line[len("data:") :].strip())
event = json.loads("\n".join(data))
except json.JSONDecodeError:
continue
finally:
data = []
if event_id is not None and on_event_id is not None:
on_event_id(event_id)
yield event
Comment thread
KAJdev marked this conversation as resolved.


def tail_summary(logs: Dict[str, List[str]], lines: int = 20) -> str:
Expand Down
9 changes: 2 additions & 7 deletions runpod/cli/groups/ssh/functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import paramiko

from runpod.api.ctl_commands import get_user, update_user_settings
from runpod.api.rest import run_rest_request

SSH_FILES = os.path.expanduser("~/.runpod/ssh")

Expand All @@ -30,18 +31,12 @@ def get_user_pub_keys():
"""
Get the current user's SSH keys
"""
user = get_user()
keys = "" if user["pubKey"] is None else user["pubKey"]

# Parse the keys
keys = keys.split("\n")
keys = [key for key in keys if key != ""]
keys = run_rest_request("GET", "/v2/account/ssh-keys")["keys"]

key_list = []
for key in keys:
key_parts = key.split(" ")

# Basic validation
if len(key_parts) < 2:
continue

Expand Down
157 changes: 157 additions & 0 deletions runpod/rp_cli/logs.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
"""shared pod and serverless log views backed by the sdk stream reader."""

import asyncio
import json
from datetime import datetime, timezone

import aiohttp
import click

from runpod.api.ctl_commands import _path_segment
from runpod.api.rest import run_rest_request
from runpod.apps.logs import stream_pod_logs
from runpod.rp_cli import console as ui
from runpod.rp_cli.api import api_errors

SNAPSHOT_IDLE_SECONDS = 3
WORKER_POLL_SECONDS = 5
RECONNECT_SECONDS = 1


async def _show_logs(
pod_id, endpoint_id, worker_ids, follow, source, tail, since, as_json
):
started = datetime.now(timezone.utc)
records = []
tasks = {}
width = 0

def emit(worker, event):
if as_json:
click.echo(
json.dumps({"worker": worker, **event} if endpoint_id else event)
)
else:
prefix = f"{worker:<{width}} " if endpoint_id else ""
click.echo(f"{prefix}[{event['source']}] {event['line']}")

async def consume(worker):
cursor = None

def remember(event_id):
nonlocal cursor
cursor = event_id

while True:
stream = stream_pod_logs(
worker,
endpoint_id=endpoint_id,
log_type=source,
tail=tail,
since=since,
last_event_id=cursor,
on_event_id=remember,
idle_timeout=None if follow else SNAPSHOT_IDLE_SECONDS,
)
try:
async for event in stream:
if follow:
emit(worker, event)
elif (
datetime.fromisoformat(event["ts"].replace("Z", "+00:00"))
>= started
):
return
else:
records.append((worker, event))
except aiohttp.ClientResponseError as exc:
Comment thread
KAJdev marked this conversation as resolved.
if endpoint_id and exc.status == 404:
return
if not follow or not (exc.status == 429 or 500 <= exc.status < 600):
raise
except (aiohttp.ClientConnectionError, asyncio.TimeoutError):
if not follow:
raise
finally:
await stream.aclose()
if not follow:
return
await asyncio.sleep(RECONNECT_SECONDS)

async def attach():
nonlocal width
if pod_id:
ids = [pod_id]
else:
response = await asyncio.to_thread(
run_rest_request,
"GET",
f"/v2/serverless/{_path_segment(endpoint_id)}/workers",
)
ids = [
worker["id"]
for worker in response["workers"]
if not worker_ids or worker["id"] in worker_ids
]
width = max([width, *(len(worker) for worker in ids)])
for worker in ids:
if worker not in tasks:
tasks[worker] = asyncio.create_task(consume(worker))

try:
await attach()
if not tasks and not as_json:
ui.console.print(
"[dim]waiting for workers ...[/dim]"
if follow
else "[dim]no workers[/dim]"
)
if not follow:
await asyncio.gather(*tasks.values())
else:
while True:
await asyncio.sleep(WORKER_POLL_SECONDS)
for task in tasks.values():
if task.done():
task.result()
tasks = {
worker: task for worker, task in tasks.items() if not task.done()
}
await attach()
finally:
for task in tasks.values():
task.cancel()
await asyncio.gather(*tasks.values(), return_exceptions=True)
for worker, event in sorted(records, key=lambda row: row[1]["ts"]):
emit(worker, event)


def show_logs(
pod_id=None,
*,
endpoint_id=None,
worker_ids=(),
follow=False,
source=None,
tail=100,
since=None,
as_json=False,
):
try:
with api_errors():
asyncio.run(
_show_logs(
pod_id,
endpoint_id,
worker_ids,
follow,
source,
tail,
since,
as_json,
)
)
except KeyboardInterrupt:
return
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
raise click.ClickException(str(exc)) from exc
4 changes: 2 additions & 2 deletions runpod/rp_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -1104,12 +1104,12 @@ def undeploy(app_name, env_name, yes):


def _mount_groups() -> None:
from runpod.cli.groups.pod.commands import pod_cli
from runpod.cli.groups.ssh.commands import ssh_cli
from runpod.rp_cli.catalog import catalog
from runpod.rp_cli.pod import pod
from runpod.rp_cli.volume import volume

for command in (ssh_cli, pod_cli, catalog, volume):
for command in (ssh_cli, pod, catalog, volume):
cli.add_command(command)


Expand Down
64 changes: 64 additions & 0 deletions runpod/rp_cli/options.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""value parsing shared by resource command options."""

import shlex
from collections.abc import Iterable

import click

from runpod.rp_cli.api import list_all
from runpod.rp_cli.compute import ResolutionError


def parse_env(values: Iterable[str]) -> dict[str, str]:
env = {}
for value in values:
key, separator, content = value.partition("=")
if not key or not separator:
raise click.UsageError("invalid --env value; use KEY=VALUE")
env[key] = content
return env


def container_fields(
options: dict, current: dict | None = None, unset_env: Iterable[str] = ()
) -> dict:
current = current or {}
body = {
field: options[option]
for option, field in (
("image", "image"),
("template", "templateId"),
("args", "args"),
("disk", "disk"),
("global_networking", "globalNetworking"),
)
if options.get(option) is not None
}
for option in ("cmd", "entrypoint"):
if options.get(option) is not None:
try:
body[option] = shlex.split(options[option])
except ValueError as exc:
raise click.BadParameter(str(exc), param_hint=f"--{option}") from exc
if options.get("ports"):
body["ports"] = list(options["ports"])
if options.get("registry") is not None:
value = options["registry"]
body["registry"] = None
if value:
matches = [
registry["id"]
for registry in list_all("/v2/registries", "registries")
if value in (registry["id"], registry["name"])
]
if not matches:
raise ResolutionError(
f"unknown registry credential '{value}'. list them with rp registry list"
)
body["registry"] = matches[0]
if options.get("env") or unset_env:
env = {**(current.get("env") or {}), **parse_env(options.get("env") or ())}
for key in unset_env:
env.pop(key, None)
body["env"] = env
return body
Loading
Loading