Skip to content

Commit 3bd5325

Browse files
authored
Add cmd: apolo app logs (#3249)
1 parent e7e0544 commit 3bd5325

File tree

9 files changed

+321
-0
lines changed

9 files changed

+321
-0
lines changed

CHANGELOG.D/3249.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added new command `apolo app logs [id]` - view logs from a running app

CLI.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@
5656
* [apolo app get-values](#apolo-app-get-values)
5757
* [apolo app install](#apolo-app-install)
5858
* [apolo app list](#apolo-app-list)
59+
* [apolo app logs](#apolo-app-logs)
5960
* [apolo app ls](#apolo-app-ls)
6061
* [apolo app uninstall](#apolo-app-uninstall)
6162
* [apolo app-template](#apolo-app-template)
@@ -1418,6 +1419,7 @@ Name | Description|
14181419
| _[apolo app get-values](#apolo-app-get-values)_| Get application values |
14191420
| _[apolo app install](#apolo-app-install)_| Install an app from a YAML file |
14201421
| _[apolo app list](#apolo-app-list)_| List apps |
1422+
| _[apolo app logs](#apolo-app-logs)_| Print the logs for an app |
14211423
| _[apolo app ls](#apolo-app-ls)_| Alias to list |
14221424
| _[apolo app uninstall](#apolo-app-uninstall)_| Uninstall an app |
14231425

@@ -1493,6 +1495,30 @@ Name | Description|
14931495

14941496

14951497

1498+
### apolo app logs
1499+
1500+
Print the logs for an app.
1501+
1502+
**Usage:**
1503+
1504+
```bash
1505+
apolo app logs [OPTIONS] APP_ID
1506+
```
1507+
1508+
**Options:**
1509+
1510+
Name | Description|
1511+
|----|------------|
1512+
|_--help_|Show this message and exit.|
1513+
|_--cluster CLUSTER_|Look on a specified cluster \(the current cluster by default).|
1514+
|_--org ORG_|Look on a specified org \(the current org by default).|
1515+
|_--project PROJECT_|Look on a specified project \(the current project by default).|
1516+
|_--since DATE\_OR_TIMEDELTA_|Only return logs after a specific date \(including). Use value of format '1d2h3m4s' to specify moment in past relatively to current time.|
1517+
|_--timestamps_|Include timestamps on each line in the log output.|
1518+
1519+
1520+
1521+
14961522
### apolo app ls
14971523

14981524
Alias to list

apolo-cli/docs/app.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Operations with applications.
1616
| [_get-values_](app.md#get-values) | Get application values |
1717
| [_install_](app.md#install) | Install an app from a YAML file |
1818
| [_list_](app.md#list) | List apps |
19+
| [_logs_](app.md#logs) | Print the logs for an app |
1920
| [_ls_](app.md#ls) | Alias to list |
2021
| [_uninstall_](app.md#uninstall) | Uninstall an app |
2122

@@ -97,6 +98,32 @@ List apps.
9798

9899

99100

101+
### logs
102+
103+
Print the logs for an app
104+
105+
106+
#### Usage
107+
108+
```bash
109+
apolo app logs [OPTIONS] APP_ID
110+
```
111+
112+
Print the logs for an app.
113+
114+
#### Options
115+
116+
| Name | Description |
117+
| :--- | :--- |
118+
| _--help_ | Show this message and exit. |
119+
| _--cluster CLUSTER_ | Look on a specified cluster \(the current cluster by default\). |
120+
| _--org ORG_ | Look on a specified org \(the current org by default\). |
121+
| _--project PROJECT_ | Look on a specified project \(the current project by default\). |
122+
| _--since DATE\_OR\_TIMEDELTA_ | Only return logs after a specific date \(including\). Use value of format '1d2h3m4s' to specify moment in past relatively to current time. |
123+
| _--timestamps_ | Include timestamps on each line in the log output. |
124+
125+
126+
100127
### ls
101128

102129
Alias to list

apolo-cli/src/apolo_cli/apps.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import codecs
12
import sys
23
from typing import List, Optional
34

@@ -12,6 +13,7 @@
1213
SimpleAppValuesFormatter,
1314
)
1415
from .formatters.apps import AppsFormatter, BaseAppsFormatter, SimpleAppsFormatter
16+
from .job import _parse_date
1517
from .root import Root
1618
from .utils import alias, argument, command, group, option
1719

@@ -238,8 +240,71 @@ async def get_values(
238240
root.print("No app values found.")
239241

240242

243+
@command()
244+
@argument("app_id")
245+
@option(
246+
"--since",
247+
metavar="DATE_OR_TIMEDELTA",
248+
help="Only return logs after a specific date (including). "
249+
"Use value of format '1d2h3m4s' to specify moment in "
250+
"past relatively to current time.",
251+
)
252+
@option(
253+
"--timestamps",
254+
is_flag=True,
255+
help="Include timestamps on each line in the log output.",
256+
)
257+
@option(
258+
"--cluster",
259+
type=CLUSTER,
260+
help="Look on a specified cluster (the current cluster by default).",
261+
)
262+
@option(
263+
"--org",
264+
type=ORG,
265+
help="Look on a specified org (the current org by default).",
266+
)
267+
@option(
268+
"--project",
269+
type=PROJECT,
270+
help="Look on a specified project (the current project by default).",
271+
)
272+
async def logs(
273+
root: Root,
274+
app_id: str,
275+
since: Optional[str],
276+
timestamps: bool,
277+
cluster: Optional[str],
278+
org: Optional[str],
279+
project: Optional[str],
280+
) -> None:
281+
"""
282+
Print the logs for an app.
283+
"""
284+
decoder = codecs.lookup("utf8").incrementaldecoder("replace")
285+
286+
async with root.client.apps.logs(
287+
app_id=app_id,
288+
cluster_name=cluster,
289+
org_name=org,
290+
project_name=project,
291+
since=_parse_date(since) if since else None,
292+
timestamps=timestamps,
293+
) as it:
294+
async for chunk in it:
295+
if not chunk: # pragma: no cover
296+
txt = decoder.decode(b"", final=True)
297+
if not txt:
298+
break
299+
else:
300+
txt = decoder.decode(chunk)
301+
sys.stdout.write(txt)
302+
sys.stdout.flush()
303+
304+
241305
app.add_command(list)
242306
app.add_command(alias(list, "ls", help="Alias to list", deprecated=False))
243307
app.add_command(install)
244308
app.add_command(uninstall)
245309
app.add_command(get_values)
310+
app.add_command(logs)

apolo-cli/tests/unit/test_apps.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,63 @@ def test_app_get_values_quiet_mode(run_cli: _RunCli) -> None:
287287
external_api_value += '{"url": "https://api.example.com"}'
288288
assert external_api_value in capture.out
289289
assert capture.code == 0
290+
291+
292+
@contextmanager
293+
def mock_apps_logs() -> Iterator[None]:
294+
"""Context manager to mock the Apps.logs method."""
295+
with mock.patch.object(Apps, "logs") as mocked:
296+
297+
@asynccontextmanager
298+
async def async_cm(**kwargs: Any) -> AsyncIterator[AsyncIterator[bytes]]:
299+
async def async_iterator() -> AsyncIterator[bytes]:
300+
logs = [
301+
b"Starting app...\n",
302+
b"App initialized\n",
303+
b"App ready\n",
304+
]
305+
for log in logs:
306+
yield log
307+
308+
yield async_iterator()
309+
310+
mocked.side_effect = async_cm
311+
yield
312+
313+
314+
def test_app_logs(run_cli: _RunCli) -> None:
315+
"""Test the app logs command."""
316+
app_id = "app-123"
317+
318+
with mock_apps_logs():
319+
capture = run_cli(["app", "logs", app_id])
320+
321+
assert not capture.err
322+
assert "Starting app..." in capture.out
323+
assert "App initialized" in capture.out
324+
assert "App ready" in capture.out
325+
assert capture.code == 0
326+
327+
328+
def test_app_logs_with_since(run_cli: _RunCli) -> None:
329+
"""Test the app logs command with since parameter."""
330+
app_id = "app-123"
331+
332+
with mock_apps_logs():
333+
capture = run_cli(["app", "logs", app_id, "--since", "1h"])
334+
335+
assert not capture.err
336+
assert "Starting app..." in capture.out
337+
assert capture.code == 0
338+
339+
340+
def test_app_logs_with_timestamps(run_cli: _RunCli) -> None:
341+
"""Test the app logs command with timestamps."""
342+
app_id = "app-123"
343+
344+
with mock_apps_logs():
345+
capture = run_cli(["app", "logs", app_id, "--timestamps"])
346+
347+
assert not capture.err
348+
assert "Starting app..." in capture.out
349+
assert capture.code == 0

apolo-cli/tests/unit/test_shell_completion.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1577,6 +1577,8 @@ def test_app_autocomplete(run_autocomplete: _RunAC) -> None:
15771577
assert "uninstall" in zsh_out
15781578
assert "get-values" in bash_out
15791579
assert "get-values" in zsh_out
1580+
assert "logs" in bash_out
1581+
assert "logs" in zsh_out
15801582

15811583

15821584
@skip_on_windows

apolo-sdk/docs/apps.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ Apps
5252
:param str org_name: org to get values from. Default is current org.
5353
:param str project_name: project to get values from. Default is current project.
5454

55+
.. method:: logs(app_id: str, *, cluster_name: Optional[str] = None, org_name: Optional[str] = None, project_name: Optional[str] = None, since: Optional[datetime] = None, timestamps: bool = False) -> AsyncContextManager[AsyncIterator[bytes]]
56+
:async:
57+
58+
Get logs for an app instance, async iterator. Yields chunks of logs as :class:`bytes`.
59+
60+
:param str app_id: The ID of the app instance.
61+
:param str cluster_name: Cluster where the app is deployed. Default is current cluster.
62+
:param str org_name: Organization where the app is deployed. Default is current org.
63+
:param str project_name: Project where the app is deployed. Default is current project.
64+
:param datetime since: Optional timestamp to start logs from.
65+
:param bool timestamps: Include timestamps in the logs output.
66+
5567
===
5668

5769
.. class:: App

apolo-sdk/src/apolo_sdk/_apps.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from dataclasses import dataclass, field
2+
from datetime import datetime
23
from typing import Any, AsyncIterator, List, Optional
34

5+
from aiohttp import WSMsgType
46
from yarl import URL
57

68
from ._config import Config
@@ -76,6 +78,13 @@ def _build_base_url(
7678
)
7779
return url
7880

81+
def _get_monitoring_url(self, cluster_name: Optional[str]) -> URL:
82+
if cluster_name is None:
83+
cluster_name = self._config.cluster_name
84+
return self._config.get_cluster(cluster_name).monitoring_url.with_path(
85+
"/api/v1"
86+
)
87+
7988
@asyncgeneratorcontextmanager
8089
async def list(
8190
self,
@@ -265,3 +274,58 @@ async def list_template_versions(
265274
short_description=item.get("short_description", ""),
266275
tags=item.get("tags", []),
267276
)
277+
278+
@asyncgeneratorcontextmanager
279+
async def logs(
280+
self,
281+
app_id: str,
282+
*,
283+
cluster_name: Optional[str] = None,
284+
org_name: Optional[str] = None,
285+
project_name: Optional[str] = None,
286+
since: Optional[datetime] = None,
287+
timestamps: bool = False,
288+
) -> AsyncIterator[bytes]:
289+
"""Get logs for an app instance.
290+
291+
Args:
292+
app_id: The ID of the app instance
293+
cluster_name: Optional cluster name override
294+
org_name: Optional organization name override
295+
project_name: Optional project name override
296+
since: Optional timestamp to start logs from
297+
timestamps: Include timestamps in the logs output
298+
299+
Returns:
300+
An async iterator of log chunks as bytes
301+
"""
302+
url = self._get_monitoring_url(cluster_name) / "apps" / app_id / "log_ws"
303+
304+
if url.scheme == "https": # pragma: no cover
305+
url = url.with_scheme("wss")
306+
else:
307+
url = url.with_scheme("ws")
308+
309+
if since is not None:
310+
if since.tzinfo is None:
311+
# Interpret naive datetime object as local time.
312+
since = since.astimezone() # pragma: no cover
313+
url = url.update_query(since=since.isoformat())
314+
if timestamps:
315+
url = url.update_query(timestamps="true")
316+
317+
auth = await self._config._api_auth()
318+
async with self._core.ws_connect(
319+
url,
320+
auth=auth,
321+
timeout=None,
322+
heartbeat=30,
323+
) as ws:
324+
async for msg in ws:
325+
if msg.type == WSMsgType.BINARY:
326+
if msg.data:
327+
yield msg.data
328+
elif msg.type == WSMsgType.ERROR: # pragma: no cover
329+
raise ws.exception() # type: ignore
330+
else: # pragma: no cover
331+
raise RuntimeError(f"Incorrect WebSocket message: {msg!r}")

0 commit comments

Comments
 (0)