logtap 0.3.0__py3-none-any.whl → 0.4.1__py3-none-any.whl

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.
logtap/__init__.py CHANGED
@@ -4,5 +4,5 @@ logtap - A CLI-first log access tool for Unix systems.
4
4
  Remote log file access without SSH. No database. No complex setup.
5
5
  """
6
6
 
7
- __version__ = "0.3.0"
7
+ __version__ = "0.4.0"
8
8
  __author__ = "Kyle Cain"
logtap/api/app.py CHANGED
@@ -1,15 +1,22 @@
1
1
  """FastAPI application factory for logtap."""
2
2
 
3
+ import os
4
+ import time
5
+ from pathlib import Path
6
+
3
7
  from fastapi import FastAPI
4
8
  from fastapi.middleware.cors import CORSMiddleware
5
9
 
6
10
  from logtap import __version__
7
- from logtap.api.routes import files, health, logs, parsed
11
+ from logtap.api.routes import files, health, logs, parsed, runs
12
+ from logtap.core.runs import RunStore
8
13
 
9
14
 
10
15
  def create_app() -> FastAPI:
11
16
  """
12
- Create and configure the FastAPI application.
17
+ Create and configure the FastAPI application for serve mode.
18
+
19
+ Serves static log files from a directory (legacy mode).
13
20
 
14
21
  Returns:
15
22
  Configured FastAPI application instance.
@@ -23,6 +30,10 @@ def create_app() -> FastAPI:
23
30
  openapi_url="/openapi.json",
24
31
  )
25
32
 
33
+ # Store mode info
34
+ app.state.mode = "serve"
35
+ app.state.features = ["files"]
36
+
26
37
  # Configure CORS
27
38
  app.add_middleware(
28
39
  CORSMiddleware,
@@ -41,5 +52,60 @@ def create_app() -> FastAPI:
41
52
  return app
42
53
 
43
54
 
44
- # Create default app instance for uvicorn
55
+ def create_collector_app() -> FastAPI:
56
+ """
57
+ Create and configure the FastAPI application for collector mode.
58
+
59
+ Accepts ingested log streams and serves them for tailing.
60
+ This is the recommended mode for ML training logs.
61
+
62
+ Returns:
63
+ Configured FastAPI application instance.
64
+ """
65
+ app = FastAPI(
66
+ title="logtap",
67
+ description="tail -f for GPU clouds. Survives disconnects, aggregates multi-node.",
68
+ version=__version__,
69
+ docs_url="/docs",
70
+ redoc_url="/redoc",
71
+ openapi_url="/openapi.json",
72
+ )
73
+
74
+ # Store mode info and start time
75
+ app.state.mode = "collect"
76
+ app.state.features = ["runs"]
77
+ app.state.start_time = time.time()
78
+
79
+ # Configure CORS
80
+ app.add_middleware(
81
+ CORSMiddleware,
82
+ allow_origins=["*"],
83
+ allow_credentials=True,
84
+ allow_methods=["*"],
85
+ allow_headers=["*"],
86
+ )
87
+
88
+ # Initialize run store from environment
89
+ data_dir = Path(os.environ.get("LOGTAP_DATA_DIR", "~/.logtap/runs")).expanduser()
90
+ buffer_lines = int(os.environ.get("LOGTAP_BUFFER_LINES", "100000"))
91
+ max_disk_mb = int(os.environ.get("LOGTAP_MAX_DISK_MB", "1000"))
92
+ retention_hours = int(os.environ.get("LOGTAP_RETENTION_HOURS", "72"))
93
+
94
+ run_store = RunStore(
95
+ data_dir=data_dir,
96
+ buffer_lines=buffer_lines,
97
+ max_disk_mb=max_disk_mb,
98
+ retention_hours=retention_hours,
99
+ )
100
+ runs.set_run_store(run_store)
101
+ app.state.run_store = run_store
102
+
103
+ # Include routers
104
+ app.include_router(health.router, tags=["health"])
105
+ app.include_router(runs.router, prefix="/runs", tags=["runs"])
106
+
107
+ return app
108
+
109
+
110
+ # Create default app instance for uvicorn (serve mode)
45
111
  app = create_app()
@@ -1,6 +1,8 @@
1
1
  """Health check endpoint for logtap."""
2
2
 
3
- from fastapi import APIRouter
3
+ import time
4
+
5
+ from fastapi import APIRouter, Request
4
6
 
5
7
  from logtap import __version__
6
8
  from logtap.models.responses import HealthResponse
@@ -9,11 +11,31 @@ router = APIRouter()
9
11
 
10
12
 
11
13
  @router.get("/health", response_model=HealthResponse)
12
- async def health_check() -> HealthResponse:
14
+ async def health_check(request: Request) -> HealthResponse:
13
15
  """
14
16
  Check the health of the logtap service.
15
17
 
16
18
  Returns:
17
- Health status and version information.
19
+ Health status, version, mode, and capability information.
18
20
  """
19
- return HealthResponse(status="healthy", version=__version__)
21
+ mode = getattr(request.app.state, "mode", "serve")
22
+ features = getattr(request.app.state, "features", ["files"])
23
+
24
+ # Get run count if in collect mode
25
+ runs_count = None
26
+ if hasattr(request.app.state, "run_store"):
27
+ runs_count = len(request.app.state.run_store.list_runs())
28
+
29
+ # Get uptime if start_time is set
30
+ uptime = None
31
+ if hasattr(request.app.state, "start_time"):
32
+ uptime = int(time.time() - request.app.state.start_time)
33
+
34
+ return HealthResponse(
35
+ status="healthy",
36
+ version=__version__,
37
+ mode=mode,
38
+ features=features,
39
+ runs=runs_count,
40
+ uptime_seconds=uptime,
41
+ )
logtap/api/routes/logs.py CHANGED
@@ -11,42 +11,39 @@ from starlette.responses import StreamingResponse
11
11
  from logtap.api.dependencies import get_settings, verify_api_key
12
12
  from logtap.core.reader import tail_async
13
13
  from logtap.core.search import filter_lines
14
- from logtap.core.validation import is_filename_valid, is_limit_valid, is_search_term_valid
14
+ from logtap.core.validation import (
15
+ is_limit_valid,
16
+ is_search_term_valid,
17
+ resolve_safe_path,
18
+ )
15
19
  from logtap.models.config import Settings
16
20
  from logtap.models.responses import LogResponse
17
21
 
18
22
  router = APIRouter()
19
23
 
20
24
 
21
- # Error messages (matching original for backward compatibility)
22
- ERROR_INVALID_FILENAME = 'Invalid filename: must not contain ".." or start with "/"'
25
+ # Error messages
26
+ ERROR_INVALID_FILENAME = "Invalid filename"
23
27
  ERROR_LONG_SEARCH_TERM = "Search term is too long: must be 100 characters or fewer"
24
28
  ERROR_INVALID_LIMIT = "Invalid limit value: must be between 1 and 1000"
25
29
 
26
30
 
27
- def validate_filename(filename: str) -> None:
28
- """Validate filename and raise HTTPException if invalid."""
29
- if not is_filename_valid(filename):
30
- raise HTTPException(
31
- status_code=status.HTTP_400_BAD_REQUEST,
32
- detail=ERROR_INVALID_FILENAME,
33
- )
34
- # Block any filename with path separators
35
- if "/" in filename or "\\" in filename:
31
+ def get_filepath(filename: str, settings: Settings) -> str:
32
+ """Get full filepath and validate it exists and is within allowed directory."""
33
+ log_dir = settings.get_log_directory()
34
+ # resolve_safe_path handles: NUL bytes, control chars, path traversal,
35
+ # separators, absolute paths, symlink escape, and containment check
36
+ filepath = resolve_safe_path(log_dir, filename)
37
+ if filepath is None:
36
38
  raise HTTPException(
37
39
  status_code=status.HTTP_400_BAD_REQUEST,
38
40
  detail=ERROR_INVALID_FILENAME,
39
41
  )
40
-
41
-
42
- def get_filepath(filename: str, settings: Settings) -> str:
43
- """Get full filepath and validate it exists."""
44
- log_dir = settings.get_log_directory()
45
- filepath = os.path.join(log_dir, filename)
42
+ # Separate existence check for correct 404 response
46
43
  if not os.path.isfile(filepath):
47
44
  raise HTTPException(
48
45
  status_code=status.HTTP_404_NOT_FOUND,
49
- detail=f"File not found: {filepath} does not exist",
46
+ detail=f"File not found: {filename} does not exist",
50
47
  )
51
48
  return filepath
52
49
 
@@ -67,8 +64,6 @@ async def get_logs(
67
64
  This endpoint reads the last N lines from a log file and optionally
68
65
  filters them by a search term or regex pattern.
69
66
  """
70
- validate_filename(filename)
71
-
72
67
  if term and not is_search_term_valid(term):
73
68
  raise HTTPException(
74
69
  status_code=status.HTTP_400_BAD_REQUEST,
@@ -126,8 +121,11 @@ async def get_logs_multi(
126
121
 
127
122
  for filename in file_list:
128
123
  try:
129
- validate_filename(filename)
130
- filepath = os.path.join(log_dir, filename)
124
+ filepath = resolve_safe_path(log_dir, filename)
125
+
126
+ if filepath is None:
127
+ results[filename] = {"error": ERROR_INVALID_FILENAME, "lines": []}
128
+ continue
131
129
 
132
130
  if not os.path.isfile(filepath):
133
131
  results[filename] = {"error": "File not found", "lines": []}
@@ -164,16 +162,14 @@ async def stream_logs(
164
162
  # Get settings (can't use Depends in WebSocket easily)
165
163
  settings = get_settings()
166
164
 
167
- try:
168
- validate_filename(filename)
169
- except HTTPException as e:
170
- await websocket.send_json({"error": e.detail})
165
+ log_dir = settings.get_log_directory()
166
+ filepath = resolve_safe_path(log_dir, filename)
167
+
168
+ if filepath is None:
169
+ await websocket.send_json({"error": ERROR_INVALID_FILENAME})
171
170
  await websocket.close()
172
171
  return
173
172
 
174
- log_dir = settings.get_log_directory()
175
- filepath = os.path.join(log_dir, filename)
176
-
177
173
  if not os.path.isfile(filepath):
178
174
  await websocket.send_json({"error": f"File not found: {filename}"})
179
175
  await websocket.close()
@@ -221,7 +217,6 @@ async def stream_logs_sse(
221
217
 
222
218
  Alternative to WebSocket for simpler clients.
223
219
  """
224
- validate_filename(filename)
225
220
  filepath = get_filepath(filename, settings)
226
221
 
227
222
  async def event_generator():
@@ -9,6 +9,7 @@ from logtap.api.dependencies import get_settings, verify_api_key
9
9
  from logtap.core.parsers import AutoParser, LogLevel
10
10
  from logtap.core.reader import tail_async
11
11
  from logtap.core.search import filter_entries
12
+ from logtap.core.validation import resolve_safe_path
12
13
  from logtap.models.config import Settings
13
14
 
14
15
  router = APIRouter()
@@ -40,21 +41,21 @@ async def get_parsed_logs(
40
41
 
41
42
  Supported formats: syslog, JSON, nginx, apache (auto-detected).
42
43
  """
43
- # Validate filename
44
- if ".." in filename or filename.startswith("/") or "/" in filename or "\\" in filename:
44
+ # Validate filename and resolve safe path
45
+ log_dir = settings.get_log_directory()
46
+ # resolve_safe_path handles all path security: traversal, symlinks, containment
47
+ filepath = resolve_safe_path(log_dir, filename)
48
+
49
+ if filepath is None:
45
50
  raise HTTPException(
46
51
  status_code=status.HTTP_400_BAD_REQUEST,
47
52
  detail='Invalid filename: must not contain ".." or start with "/"',
48
53
  )
49
54
 
50
- # Build file path
51
- log_dir = settings.get_log_directory()
52
- filepath = os.path.join(log_dir, filename)
53
-
54
55
  if not os.path.isfile(filepath):
55
56
  raise HTTPException(
56
57
  status_code=status.HTTP_404_NOT_FOUND,
57
- detail=f"File not found: {filepath} does not exist",
58
+ detail=f"File not found: {filename} does not exist",
58
59
  )
59
60
 
60
61
  # Read file lines
@@ -0,0 +1,330 @@
1
+ """Routes for run management (collector mode)."""
2
+
3
+ import asyncio
4
+ from typing import List, Optional
5
+
6
+ from fastapi import APIRouter, Depends, Header, HTTPException, Query, Request, Response
7
+ from fastapi.responses import StreamingResponse
8
+
9
+ from logtap.api.dependencies import verify_api_key
10
+ from logtap.core.runs import RunStore
11
+ from logtap.models.responses import (
12
+ IngestResponse,
13
+ RunInfo,
14
+ RunListResponse,
15
+ StreamLineEvent,
16
+ StreamMetaEvent,
17
+ )
18
+
19
+ router = APIRouter()
20
+
21
+ # Global run store - will be set by app factory
22
+ _run_store: Optional[RunStore] = None
23
+
24
+
25
+ def get_run_store() -> RunStore:
26
+ """Get the run store instance."""
27
+ if _run_store is None:
28
+ raise HTTPException(status_code=500, detail="Run store not initialized")
29
+ return _run_store
30
+
31
+
32
+ def set_run_store(store: RunStore) -> None:
33
+ """Set the global run store instance."""
34
+ global _run_store
35
+ _run_store = store
36
+
37
+
38
+ def parse_tags(tag_headers: Optional[List[str]]) -> dict:
39
+ """Parse X-Logtap-Tag headers into dict."""
40
+ if not tag_headers:
41
+ return {}
42
+
43
+ tags = {}
44
+ for tag in tag_headers:
45
+ if "=" in tag:
46
+ key, value = tag.split("=", 1)
47
+ tags[key.strip()] = value.strip()
48
+ return tags
49
+
50
+
51
+ @router.get("", response_model=RunListResponse)
52
+ async def list_runs(
53
+ since_hours: Optional[int] = Query(None, description="Filter to runs active within N hours"),
54
+ _: None = Depends(verify_api_key),
55
+ store: RunStore = Depends(get_run_store),
56
+ ) -> RunListResponse:
57
+ """List all runs."""
58
+ runs = store.list_runs(since_hours=since_hours)
59
+
60
+ return RunListResponse(
61
+ runs=[
62
+ RunInfo(
63
+ id=run.id,
64
+ lines=run.metadata.lines_count,
65
+ cursor_earliest=run.cursor_earliest,
66
+ cursor_latest=run.cursor_latest,
67
+ tags=run.metadata.tags,
68
+ created_at=run.metadata.created_at,
69
+ last_activity=run.metadata.last_activity,
70
+ active=run.metadata.active,
71
+ bytes_on_disk=run.metadata.bytes_on_disk,
72
+ )
73
+ for run in runs
74
+ ]
75
+ )
76
+
77
+
78
+ @router.get("/{run_id}", response_model=RunInfo)
79
+ async def get_run(
80
+ run_id: str,
81
+ _: None = Depends(verify_api_key),
82
+ store: RunStore = Depends(get_run_store),
83
+ ) -> RunInfo:
84
+ """Get details for a specific run."""
85
+ run = store.get(run_id)
86
+ if run is None:
87
+ raise HTTPException(
88
+ status_code=404,
89
+ detail={"error": "run_not_found", "message": f"Run '{run_id}' does not exist"},
90
+ )
91
+
92
+ return RunInfo(
93
+ id=run.id,
94
+ lines=run.metadata.lines_count,
95
+ cursor_earliest=run.cursor_earliest,
96
+ cursor_latest=run.cursor_latest,
97
+ tags=run.metadata.tags,
98
+ created_at=run.metadata.created_at,
99
+ last_activity=run.metadata.last_activity,
100
+ active=run.metadata.active,
101
+ bytes_on_disk=run.metadata.bytes_on_disk,
102
+ )
103
+
104
+
105
+ @router.post("/{run_id}/ingest", response_model=IngestResponse)
106
+ async def ingest(
107
+ run_id: str,
108
+ request: Request,
109
+ response: Response,
110
+ x_logtap_tag: Optional[List[str]] = Header(None),
111
+ _: None = Depends(verify_api_key),
112
+ store: RunStore = Depends(get_run_store),
113
+ ) -> IngestResponse:
114
+ """
115
+ Ingest log lines for a run.
116
+
117
+ Send lines as plain text with newline delimiters.
118
+ Supports chunked transfer encoding for streaming.
119
+ """
120
+ # Check storage
121
+ storage_err = store.check_storage()
122
+ if storage_err:
123
+ raise HTTPException(
124
+ status_code=507,
125
+ detail={"error": "insufficient_storage", "message": "Disk limit exceeded"},
126
+ )
127
+
128
+ # Get or create run
129
+ run, created = store.get_or_create(run_id)
130
+
131
+ # Handle tags (validate and track in metadata)
132
+ tags = parse_tags(x_logtap_tag)
133
+ if tags:
134
+ err = run.set_tags(tags)
135
+ if err:
136
+ raise HTTPException(
137
+ status_code=400,
138
+ detail={"error": "invalid_tag", "message": err},
139
+ )
140
+
141
+ # Read and ingest body
142
+ lines_ingested = 0
143
+ buffer = ""
144
+
145
+ async for chunk in request.stream():
146
+ text = chunk.decode("utf-8", errors="replace")
147
+ buffer += text
148
+
149
+ # Process complete lines
150
+ while "\n" in buffer:
151
+ line, buffer = buffer.split("\n", 1)
152
+ run.append(line, tags) # Pass tags to each line
153
+ lines_ingested += 1
154
+
155
+ # Flush remaining partial line
156
+ if buffer:
157
+ run.append(buffer, tags) # Pass tags to each line
158
+ lines_ingested += 1
159
+
160
+ # Save metadata
161
+ run._save_metadata()
162
+
163
+ # Set status code: 201 for new run, 200 for existing
164
+ response.status_code = 201 if created else 200
165
+
166
+ return IngestResponse(
167
+ run_id=run_id,
168
+ lines_ingested=lines_ingested,
169
+ cursor_end=run.cursor_latest,
170
+ )
171
+
172
+
173
+ @router.get("/{run_id}/stream")
174
+ async def stream_run(
175
+ run_id: str,
176
+ since: Optional[int] = Query(None, description="Cursor to resume from (exclusive)"),
177
+ tail: int = Query(50, description="Lines to show if since not provided"),
178
+ follow: bool = Query(False, description="Keep connection open for new lines"),
179
+ tag: Optional[List[str]] = Query(None, description="Filter by tag (key=value)"),
180
+ _: None = Depends(verify_api_key),
181
+ store: RunStore = Depends(get_run_store),
182
+ ) -> StreamingResponse:
183
+ """
184
+ Stream lines from a run using Server-Sent Events.
185
+
186
+ Supports resume via `since` parameter or Last-Event-ID header.
187
+ """
188
+ run = store.get(run_id)
189
+ if run is None:
190
+ raise HTTPException(
191
+ status_code=404,
192
+ detail={"error": "run_not_found", "message": f"Run '{run_id}' does not exist"},
193
+ )
194
+
195
+ # Parse tag filter for per-line filtering
196
+ tag_filter = parse_tags(tag) if tag else None
197
+
198
+ async def generate_sse():
199
+ # Get initial lines and check for gap
200
+ lines, gap = run.get_lines(since=since, tail=tail, tag_filter=tag_filter)
201
+
202
+ # Send meta event
203
+ missed = None
204
+ if gap and since is not None:
205
+ missed = run.cursor_earliest - since - 1
206
+ if missed < 0:
207
+ missed = 0
208
+
209
+ meta = StreamMetaEvent(
210
+ cursor_earliest=run.cursor_earliest,
211
+ cursor_latest=run.cursor_latest,
212
+ gap=gap,
213
+ missed=missed,
214
+ )
215
+ yield f"event: meta\ndata: {meta.model_dump_json()}\n\n"
216
+
217
+ # Send initial lines
218
+ last_cursor = since if since is not None else -1
219
+ for line in lines:
220
+ event = StreamLineEvent(
221
+ cursor=line.cursor,
222
+ line=line.line,
223
+ ts=line.ts,
224
+ )
225
+ yield f"id: {line.cursor}\nevent: line\ndata: {event.model_dump_json()}\n\n"
226
+ last_cursor = line.cursor
227
+
228
+ if not follow:
229
+ return
230
+
231
+ # Follow mode - stream new lines
232
+ heartbeat_interval = 15 # seconds
233
+ last_heartbeat = asyncio.get_event_loop().time()
234
+
235
+ while True:
236
+ # Get new lines since last cursor
237
+ new_lines, _ = run.get_lines(since=last_cursor, limit=100, tag_filter=tag_filter)
238
+
239
+ for line in new_lines:
240
+ event = StreamLineEvent(
241
+ cursor=line.cursor,
242
+ line=line.line,
243
+ ts=line.ts,
244
+ )
245
+ yield f"id: {line.cursor}\nevent: line\ndata: {event.model_dump_json()}\n\n"
246
+ last_cursor = line.cursor
247
+
248
+ # Send heartbeat if needed
249
+ now = asyncio.get_event_loop().time()
250
+ if now - last_heartbeat >= heartbeat_interval:
251
+ yield ": heartbeat\n\n"
252
+ last_heartbeat = now
253
+
254
+ # Small delay before checking again
255
+ await asyncio.sleep(0.1)
256
+
257
+ return StreamingResponse(
258
+ generate_sse(),
259
+ media_type="text/event-stream",
260
+ headers={
261
+ "Cache-Control": "no-cache",
262
+ "X-Logtap-Earliest-Cursor": str(run.cursor_earliest),
263
+ "X-Logtap-Latest-Cursor": str(run.cursor_latest),
264
+ },
265
+ )
266
+
267
+
268
+ @router.get("/{run_id}/query")
269
+ async def query_run(
270
+ run_id: str,
271
+ from_cursor: Optional[int] = Query(None, alias="from", description="Start cursor (inclusive)"),
272
+ to_cursor: Optional[int] = Query(None, alias="to", description="End cursor (inclusive)"),
273
+ tail: int = Query(50, description="Last N lines (if from/to not provided)"),
274
+ limit: int = Query(1000, le=10000, description="Maximum lines to return"),
275
+ search: Optional[str] = Query(None, description="Substring filter"),
276
+ regex: Optional[str] = Query(None, description="Regex filter"),
277
+ output: str = Query("jsonl", description="Output format: jsonl or plain"),
278
+ _: None = Depends(verify_api_key),
279
+ store: RunStore = Depends(get_run_store),
280
+ ) -> StreamingResponse:
281
+ """Query lines from a run."""
282
+ # Validate search/regex mutual exclusion
283
+ if search and regex:
284
+ raise HTTPException(
285
+ status_code=400,
286
+ detail={"error": "invalid_query", "message": "Cannot use both search and regex"},
287
+ )
288
+
289
+ run = store.get(run_id)
290
+ if run is None:
291
+ raise HTTPException(
292
+ status_code=404,
293
+ detail={"error": "run_not_found", "message": f"Run '{run_id}' does not exist"},
294
+ )
295
+
296
+ # Get lines
297
+ if from_cursor is not None:
298
+ # Range query - get lines from cursor onwards
299
+ lines, _ = run.get_lines(since=from_cursor - 1, limit=limit)
300
+ if to_cursor is not None:
301
+ lines = [ln for ln in lines if ln.cursor <= to_cursor]
302
+ else:
303
+ # Tail query
304
+ lines, _ = run.get_lines(tail=tail, limit=limit)
305
+
306
+ # Apply search/regex filter
307
+ if search:
308
+ lines = [ln for ln in lines if search in ln.line]
309
+ elif regex:
310
+ import re2
311
+
312
+ try:
313
+ pattern = re2.compile(regex)
314
+ lines = [ln for ln in lines if pattern.search(ln.line)]
315
+ except re2.error:
316
+ raise HTTPException(
317
+ status_code=400,
318
+ detail={"error": "invalid_regex", "message": "Invalid regex pattern"},
319
+ )
320
+
321
+ async def generate():
322
+ for line in lines:
323
+ if output == "plain":
324
+ yield line.line + "\n"
325
+ else:
326
+ event = StreamLineEvent(cursor=line.cursor, line=line.line, ts=line.ts)
327
+ yield event.model_dump_json() + "\n"
328
+
329
+ content_type = "text/plain" if output == "plain" else "application/x-ndjson"
330
+ return StreamingResponse(generate(), media_type=content_type)