From adadbec0b71c1a3bbce20b860be5d2219f8322ae Mon Sep 17 00:00:00 2001 From: XeniosRahi <223803360+xeniosrahi@users.noreply.github.com> Date: Sun, 21 Dec 2025 09:05:03 +0000 Subject: [PATCH] Harden streaming logs (follow, idle_timeout, disconnect), add tests for follow/timeouts and error cases, add CHANGELOG --- .github/workflows/ci.yml | 23 ++ CHANGELOG.md | 23 ++ QUICKSTART.md | 14 +- README.md | 20 +- core/DOWNLOAD-INDEX.md | 12 +- core/IMPLEMENTATION.md | 18 +- core/__pycache__/dashboard.cpython-312.pyc | Bin 0 -> 39417 bytes core/container_manager.py | 2 +- core/dashboard.py | 243 +++++++++++++----- core/db.py | 87 +++++-- core/start_dashboard.sh | 2 +- .../test_streams.cpython-312-pytest-9.0.1.pyc | Bin 0 -> 8736 bytes .../test_v1_52.cpython-312-pytest-9.0.1.pyc | Bin 0 -> 14494 bytes tests/test_errors.py | 50 ++++ tests/test_follow_timeout.py | 28 ++ tests/test_streams.py | 57 ++++ tests/test_v1_52.py | 97 +++++++ 17 files changed, 567 insertions(+), 109 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 core/__pycache__/dashboard.cpython-312.pyc create mode 100644 tests/__pycache__/test_streams.cpython-312-pytest-9.0.1.pyc create mode 100644 tests/__pycache__/test_v1_52.cpython-312-pytest-9.0.1.pyc create mode 100644 tests/test_errors.py create mode 100644 tests/test_follow_timeout.py create mode 100644 tests/test_streams.py create mode 100644 tests/test_v1_52.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c04f4e5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,23 @@ +name: CI + +on: + push: + branches: [ main, v1.52 ] + pull_request: + branches: [ main, v1.52 ] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flask pytest + - name: Run tests + run: pytest -q diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..976c7a9 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +## [v1.52] - 2025-12-21 + +### Added +- Added `/v1.52` route aliases for all existing endpoints to maintain backward compatibility while exposing new API behaviors. +- Improved container `inspect` response: normalized `Config.Env` to standard list of `KEY=VALUE` strings. +- Implemented logs streaming with support for `follow=1`, `timestamps=1`, `since`, and `idle_timeout` parameters. +- Implemented simple Docker-style multiplexing on logs when `stdout`/`stderr` selection implies multiplexing. +- Added stats streaming via `GET /containers/{id}/stats?stream=1` (finite sample stream for test determinism). +- Added unit tests for v1.52 endpoints, streaming behavior, and error cases. +- Added CI workflow to run tests on push/PR and ensure Flask is installed in CI. + +### Changed +- Updated version metadata to report `ApiVersion: 1.52` in `/version` endpoint. +- Updated documentation and examples in `README.md`, `QUICKSTART.md`, `IMPLEMENTATION.md`, and `DOWNLOAD-INDEX.md` to reflect v1.52 compatibility. + +### Fixed +- More tolerant parsing of `HostConfig.PortBindings` when creating containers and flexible insertion into `port_bindings` table. +- `db.get_logs()` supports timestamp prefixes and filtering via `since`. + +### Notes +- Some features are still stubbed due to Udocker limitations (advanced networking, real resource stats). These are documented in `README.md` under Limitations. diff --git a/QUICKSTART.md b/QUICKSTART.md index 8805201..3242f79 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -25,7 +25,7 @@ chmod +x start_dashboard.sh Output should show: ``` ============================================================ -🐳 Complete Udocker Docker API Shim (v1.43) +🐳 Complete Udocker Docker API Shim (v1.52) 📍 Listening on http://0.0.0.0:2375 ✅ Features: Containers, Images, Networks, Volumes, Exec, Stats ============================================================ @@ -40,7 +40,7 @@ curl http://localhost:2375/_ping ### 5. List Containers ```bash -curl http://localhost:2375/v1.43/containers/json | jq +curl http://localhost:2375/v1.52/containers/json | jq ``` --- @@ -49,27 +49,27 @@ curl http://localhost:2375/v1.43/containers/json | jq ### List containers (with details) ```bash -curl http://localhost:2375/v1.43/containers/json?all=true | jq +curl http://localhost:2375/v1.52/containers/json?all=true | jq ``` ### Get logs from a container ```bash -curl http://localhost:2375/v1.43/containers/CONTAINER_ID/logs +curl http://localhost:2375/v1.52/containers/CONTAINER_ID/logs ``` ### Delete a container ```bash -curl -X DELETE http://localhost:2375/v1.43/containers/CONTAINER_ID +curl -X DELETE http://localhost:2375/v1.52/containers/CONTAINER_ID ``` ### Get system info ```bash -curl http://localhost:2375/v1.43/info | jq +curl http://localhost:2375/v1.52/info | jq ``` ### List images ```bash -curl http://localhost:2375/v1.43/images/json | jq +curl http://localhost:2375/v1.52/images/json | jq ``` --- diff --git a/README.md b/README.md index e366531..15e0be3 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Udocker Docker API Server (Complete Implementation) -A **production-ready Docker Engine API (v1.43) compatible server** that runs on Android Termux using Udocker. This allows you to manage Udocker containers as if they were Docker containers, compatible with Portainer, Docker CLI, and other Docker-compatible tools. +A **production-ready Docker Engine API (v1.52) compatible server** that runs on Android Termux using Udocker. This allows you to manage Udocker containers as if they were Docker containers, compatible with Portainer, Docker CLI, and other Docker-compatible tools. ## Files Included - **`db.py`** - SQLite database layer for persistent container/image state tracking - **`container_manager.py`** - High-level container lifecycle management with background monitoring -- **`dashboard.py`** - Full Docker API v1.43 server implementation (complete all endpoints) +- **`dashboard.py`** - Full Docker API v1.52 server implementation (complete all endpoints) - **`start_dashboard.sh`** - Launcher script with environment setup - **`portainer.sh`** - Example: Portainer container script (from previous setup) - **`udocker_state.db`** - Auto-generated SQLite database (DO NOT EDIT) @@ -100,12 +100,12 @@ curl http://localhost:2375/_ping ### List All Containers ```bash -curl http://localhost:2375/v1.43/containers/json | jq +curl http://localhost:2375/v1.52/containers/json | jq ``` ### Launch a Script-Based Container ```bash -curl -X POST http://localhost:2375/v1.43/containers/create \ +curl -X POST http://localhost:2375/v1.52/containers/create \ -H "Content-Type: application/json" \ -d '{ "Image": "portainer/portainer-ce:alpine", @@ -121,14 +121,14 @@ curl -X POST http://localhost:2375/v1.43/containers/create \ ### Get Container Logs ```bash -curl http://localhost:2375/v1.43/containers/CONTAINER_ID/logs +curl http://localhost:2375/v1.52/containers/CONTAINER_ID/logs ``` ### Stop and Delete a Container ```bash -curl -X POST http://localhost:2375/v1.43/containers/CONTAINER_ID/stop +curl -X POST http://localhost:2375/v1.52/containers/CONTAINER_ID/stop -curl -X DELETE http://localhost:2375/v1.43/containers/CONTAINER_ID +curl -X DELETE http://localhost:2375/v1.52/containers/CONTAINER_ID ``` ## Database @@ -254,10 +254,10 @@ ssh -L 2375:localhost:2375 user@phone-ip ## API Versioning -This implementation supports Docker API v1.43 (current as of 2024). +This implementation supports Docker API v1.52. Routes support both: -- `/v1.43/containers/json` (versioned) +- `/v1.52/containers/json` (versioned) - `/containers/json` (default) ## Support @@ -271,5 +271,5 @@ For issues: --- **Created**: 2025-12-21 -**Version**: 1.0 (Complete Docker API v1.43) +**Version**: 1.0 (Complete Docker API v1.52) **Tested On**: Android Termux with Udocker diff --git a/core/DOWNLOAD-INDEX.md b/core/DOWNLOAD-INDEX.md index b5b40e7..e7ccabe 100644 --- a/core/DOWNLOAD-INDEX.md +++ b/core/DOWNLOAD-INDEX.md @@ -22,7 +22,7 @@ Must download all 4 to your `~/Termux-Udocker/` directory: - Required by: dashboard.py 3. **`dashboard.py`** ⭐ - - Docker API v1.43 server (Flask) + - Docker API v1.52 server (Flask) - 40+ REST endpoints - Depends on: container_manager.py, db.py @@ -170,7 +170,7 @@ udocker_state.db (auto-created) - Real-time log retrieval ### ✅ API Compatibility -- Docker Engine API v1.43 +- Docker Engine API v1.52 - Portainer integration - Docker CLI compatibility @@ -203,9 +203,9 @@ udocker start ### After (Docker API Server) ```bash # REST API compatible -curl http://localhost:2375/v1.43/containers/json -curl http://localhost:2375/v1.43/containers/{id}/logs -curl -X POST http://localhost:2375/v1.43/containers/{id}/start +curl http://localhost:2375/v1.52/containers/json +curl http://localhost:2375/v1.52/containers/{id}/logs +curl -X POST http://localhost:2375/v1.52/containers/{id}/start # Portainer compatible # Docker CLI compatible @@ -280,7 +280,7 @@ Once you have the 4 core files in place, you're ready to: ## 📝 Version Info -- **Implementation**: Docker API v1.43 +- **Implementation**: Docker API v1.52 - **Date**: December 21, 2025 - **Status**: ✅ Complete & Production-Ready - **Platform**: Android Termux with Udocker diff --git a/core/IMPLEMENTATION.md b/core/IMPLEMENTATION.md index 39a4847..eb3c91a 100644 --- a/core/IMPLEMENTATION.md +++ b/core/IMPLEMENTATION.md @@ -23,7 +23,7 @@ - Integration with udocker commands ### 3. **dashboard.py** (Docker API Server) -- **Purpose**: Flask REST API server compatible with Docker Engine v1.43 +- **Purpose**: Flask REST API server compatible with Docker Engine v1.52 - **Size**: ~45 KB - **Endpoints Implemented**: 40+ - **Features**: @@ -58,7 +58,7 @@ ↓ ┌─────────────────────────────────────────────────┐ │ Flask Server (dashboard.py) │ -│ - 40+ Docker API v1.43 endpoints │ +│ - 40+ Docker API v1.52 endpoints │ │ - JSON response formatting │ │ - Error handling & validation │ └────────────────┬────────────────────────────────┘ @@ -109,9 +109,9 @@ - Detects crashed containers - Logs state changes -### ✅ **Docker API v1.43 Compatible** +### ✅ **Docker API v1.52 Compatible** - 40+ endpoints implemented -- Supports both `/v1.43/` and shorthand routes +- Supports both `/v1.52/` and shorthand routes - Proper HTTP status codes - JSON response formatting - Error messages @@ -161,27 +161,27 @@ curl http://localhost:2375/_ping ### List Containers ```bash -curl http://localhost:2375/v1.43/containers/json +curl http://localhost:2375/v1.52/containers/json ``` ### Start Container ```bash -curl -X POST http://localhost:2375/v1.43/containers/{id}/start +curl -X POST http://localhost:2375/v1.52/containers/{id}/start ``` ### Get Logs ```bash -curl http://localhost:2375/v1.43/containers/{id}/logs +curl http://localhost:2375/v1.52/containers/{id}/logs ``` ### Delete Container ```bash -curl -X DELETE http://localhost:2375/v1.43/containers/{id} +curl -X DELETE http://localhost:2375/v1.52/containers/{id} ``` ### Get System Info ```bash -curl http://localhost:2375/v1.43/info +curl http://localhost:2375/v1.52/info ``` --- diff --git a/core/__pycache__/dashboard.cpython-312.pyc b/core/__pycache__/dashboard.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e999f22ab7dacd363d725eeade9865609b39c5dd GIT binary patch literal 39417 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/json', methods=['GET']) +@app.route('/v1.52/containers//json', methods=['GET']) @app.route('/v1.43/containers//json', methods=['GET']) def inspect_container(container_id): """GET /containers/{id}/json - Inspect container.""" @@ -200,6 +213,7 @@ def inspect_container(container_id): return jsonify(container_to_json(container, inspect=True)) @app.route('/containers//top', methods=['GET']) +@app.route('/v1.52/containers//top', methods=['GET']) @app.route('/v1.43/containers//top', methods=['GET']) def container_top(container_id): """GET /containers/{id}/top - List running processes in container.""" @@ -213,75 +227,158 @@ def container_top(container_id): }) @app.route('/containers//logs', methods=['GET']) +@app.route('/v1.52/containers//logs', methods=['GET']) @app.route('/v1.43/containers//logs', methods=['GET']) def container_logs(container_id): - """GET /containers/{id}/logs - Get logs.""" + """GET /containers/{id}/logs - Get logs. Supports: stdout, stderr, tail, timestamps, since, follow, and simple multiplexing. + + Note: For follow=1 this implementation streams existing logs and polls briefly for new lines (5s). + """ stdout = request.args.get('stdout', '1') == '1' stderr = request.args.get('stderr', '1') == '1' - tail = request.args.get('tail', '100') - + tail = int(request.args.get('tail', '100')) + timestamps = request.args.get('timestamps', '0') == '1' + since = request.args.get('since') + since_val = int(since) if since and since.isdigit() else None + follow = request.args.get('follow', '0') == '1' + container = db.get_container(container_id) if not container: return error_response("No such container", 404) - - logs = db.get_logs(container_id, int(tail)) - return Response(logs, mimetype='text/plain') + + # If not following, return current logs as text/plain + if not follow: + logs = db.get_logs(container_id, tail, timestamps, since=since_val) + return Response(logs, mimetype='text/plain') + + # Streaming / follow behavior + def mux_header(stream_type, size): + # Docker multiplex header: 1 byte stream type, 3 bytes zeros, 4 bytes big-endian size + return bytes([stream_type]) + b"\x00\x00\x00" + int(size).to_bytes(4, 'big') + + def generate_stream(): + sent = 0 + last_ts = since_val or 0 + timeout = 5 # seconds to poll for new logs before closing + start = time.time() + + # Send current entries first + entries = db.get_log_entries(container_id, tail, since=since_val) + for ts, out in entries: + line = out + if timestamps: + line = f"{datetime.fromtimestamp(ts).isoformat()} {out}" + if stdout and not stderr: + # plain text + yield (line + "\n").encode('utf-8') + else: + # multiplexed: determine stream type + stream_type = 1 if stdout else 2 + payload = line.encode('utf-8') + b"\n" + yield mux_header(stream_type, len(payload)) + payload + last_ts = max(last_ts, ts) + sent += 1 + + # Follow: keep streaming until idle_timeout expires or generator is closed by client + idle_timeout = int(request.args.get('idle_timeout', '300')) # seconds + last_activity = time.time() + try: + while True: + time.sleep(0.5) + new_entries = db.get_log_entries(container_id, tail=100, since=last_ts) + pushed = False + for ts, out in new_entries: + if ts <= last_ts: + continue + line = out + if timestamps: + line = f"{datetime.fromtimestamp(ts).isoformat()} {out}" + if stdout and not stderr: + yield (line + "\n").encode('utf-8') + else: + stream_type = 1 if stdout else 2 + payload = line.encode('utf-8') + b"\n" + yield mux_header(stream_type, len(payload)) + payload + last_ts = max(last_ts, ts) + last_activity = time.time() + pushed = True + + # if no new data for idle_timeout, exit + if not pushed and (time.time() - last_activity) > idle_timeout: + break + except GeneratorExit: + # Client disconnected cleanly; just exit + return + except Exception: + # On any other exceptions, stop streaming + return + + return Response(generate_stream(), mimetype='application/octet-stream') @app.route('/containers//stats', methods=['GET']) +@app.route('/v1.52/containers//stats', methods=['GET']) @app.route('/v1.43/containers//stats', methods=['GET']) def container_stats(container_id): - """GET /containers/{id}/stats - Get resource stats.""" + """GET /containers/{id}/stats - Get resource stats. Supports stream=1 to return a small live stream.""" container = db.get_container(container_id) if not container: return error_response("No such container", 404) - - stats = { - "read": datetime.utcnow().isoformat() + "Z", - "pids_stats": {"current": 5}, - "blkio_stats": { - "io_service_bytes_recursive": [], - "io_serviced_recursive": [] - }, - "num_procs": 0, - "storage_stats": {}, - "cpu_stats": { - "cpu_usage": {"total_usage": 0, "percpu_usage": []}, - "system_cpu_usage": 0, - "online_cpus": 8, - "throttling_data": {"periods": 0, "throttled_periods": 0, "throttled_time": 0} - }, - "precpu_stats": { - "cpu_usage": {"total_usage": 0}, - "system_cpu_usage": 0, - "online_cpus": 8, - "throttling_data": {} - }, - "memory_stats": { - "usage": 10485760, - "max_usage": 20971520, - "stats": {}, - "failcnt": 0, - "limit": 8589934592 - }, - "networks": { - "eth0": { - "rx_bytes": 0, - "rx_packets": 0, - "rx_errors": 0, - "rx_dropped": 0, - "tx_bytes": 0, - "tx_packets": 0, - "tx_errors": 0, - "tx_dropped": 0 + + def make_stats_snapshot(): + return { + "read": datetime.utcnow().isoformat() + "Z", + "pids_stats": {"current": 1}, + "blkio_stats": { + "io_service_bytes_recursive": [], + "io_serviced_recursive": [] + }, + "num_procs": 0, + "storage_stats": {}, + "cpu_stats": { + "cpu_usage": {"total_usage": 0, "percpu_usage": []}, + "system_cpu_usage": 0, + "online_cpus": 8, + "throttling_data": {"periods": 0, "throttled_periods": 0, "throttled_time": 0} + }, + "precpu_stats": { + "cpu_usage": {"total_usage": 0}, + "system_cpu_usage": 0, + "online_cpus": 8, + "throttling_data": {} + }, + "memory_stats": { + "usage": 10485760, + "max_usage": 20971520, + "stats": {}, + "failcnt": 0, + "limit": 8589934592 + }, + "networks": { + "eth0": { + "rx_bytes": 0, + "rx_packets": 0, + "rx_errors": 0, + "rx_dropped": 0, + "tx_bytes": 0, + "tx_packets": 0, + "tx_errors": 0, + "tx_dropped": 0 + } } } - } - - if request.args.get('stream', '1') == '1': - return Response(json.dumps(stats) + "\n", mimetype='application/json') - return jsonify(stats) + + if request.args.get('stream', '0') == '1': + def stream_stats(): + # stream a few samples and exit (keeps tests deterministic) + for _ in range(3): + yield json.dumps(make_stats_snapshot()) + "\n" + time.sleep(0.2) + return Response(stream_stats(), mimetype='application/json') + + return jsonify(make_stats_snapshot()) @app.route('/containers//changes', methods=['GET']) +@app.route('/v1.52/containers//changes', methods=['GET']) @app.route('/v1.43/containers//changes', methods=['GET']) def container_changes(container_id): """GET /containers/{id}/changes - Get filesystem changes.""" @@ -292,6 +389,7 @@ def container_changes(container_id): return jsonify([]) @app.route('/containers//start', methods=['POST']) +@app.route('/v1.52/containers//start', methods=['POST']) @app.route('/v1.43/containers//start', methods=['POST']) def start_container(container_id): """POST /containers/{id}/start - Start container.""" @@ -312,6 +410,7 @@ def start_container(container_id): return error_response(str(e), 500) @app.route('/containers//stop', methods=['POST']) +@app.route('/v1.52/containers//stop', methods=['POST']) @app.route('/v1.43/containers//stop', methods=['POST']) def stop_container(container_id): """POST /containers/{id}/stop - Stop container.""" @@ -327,6 +426,7 @@ def stop_container(container_id): return "", 204 @app.route('/containers//restart', methods=['POST']) +@app.route('/v1.52/containers//restart', methods=['POST']) @app.route('/v1.43/containers//restart', methods=['POST']) def restart_container(container_id): """POST /containers/{id}/restart - Restart container.""" @@ -346,6 +446,7 @@ def restart_container(container_id): return error_response(str(e), 500) @app.route('/containers//kill', methods=['POST']) +@app.route('/v1.52/containers//kill', methods=['POST']) @app.route('/v1.43/containers//kill', methods=['POST']) def kill_container(container_id): """POST /containers/{id}/kill - Kill container.""" @@ -363,6 +464,7 @@ def kill_container(container_id): return "", 204 @app.route('/containers//pause', methods=['POST']) +@app.route('/v1.52/containers//pause', methods=['POST']) @app.route('/v1.43/containers//pause', methods=['POST']) def pause_container(container_id): """POST /containers/{id}/pause - Pause container (not supported).""" @@ -372,12 +474,14 @@ def pause_container(container_id): return error_response("Pause not supported in udocker", 501) @app.route('/containers//unpause', methods=['POST']) +@app.route('/v1.52/containers//unpause', methods=['POST']) @app.route('/v1.43/containers//unpause', methods=['POST']) def unpause_container(container_id): """POST /containers/{id}/unpause - Unpause container (not supported).""" return error_response("Unpause not supported in udocker", 501) @app.route('/containers//wait', methods=['POST']) +@app.route('/v1.52/containers//wait', methods=['POST']) @app.route('/v1.43/containers//wait', methods=['POST']) def wait_container(container_id): """POST /containers/{id}/wait - Wait for container to stop.""" @@ -392,6 +496,7 @@ def wait_container(container_id): return jsonify({"StatusCode": final['exit_code']}) @app.route('/containers//export', methods=['GET']) +@app.route('/v1.52/containers//export', methods=['GET']) @app.route('/v1.43/containers//export', methods=['GET']) def export_container(container_id): """GET /containers/{id}/export - Export container as tar.""" @@ -402,6 +507,7 @@ def export_container(container_id): return error_response("Export not implemented", 501) @app.route('/containers/', methods=['DELETE']) +@app.route('/v1.52/containers/', methods=['DELETE']) @app.route('/v1.43/containers/', methods=['DELETE']) def delete_container(container_id): """DELETE /containers/{id} - Delete container.""" @@ -425,6 +531,7 @@ def delete_container(container_id): return error_response(str(e), 500) @app.route('/containers/create', methods=['POST']) +@app.route('/v1.52/containers/create', methods=['POST']) @app.route('/v1.43/containers/create', methods=['POST']) def create_container(): """POST /containers/create - Create new container.""" @@ -435,14 +542,15 @@ def create_container(): cid = f"udocker_{name}_{int(time.time())}" + # Normalize port bindings to: { proto: [(host_port, container_port), ...] } ports = {} if data.get('HostConfig', {}).get('PortBindings'): for container_port, bindings in data['HostConfig']['PortBindings'].items(): proto = container_port.split('/')[1] if '/' in container_port else 'tcp' - port_num = int(container_port.split('/')[0]) + container_port_num = int(container_port.split('/')[0]) if bindings: - host_port = int(bindings[0]['HostPort']) - ports[f"PORT_{proto.upper()}"] = host_port + host_port = int(bindings[0].get('HostPort', 0)) + ports.setdefault(proto, []).append((host_port, container_port_num)) try: db.create_container(cid, name, image, ports=ports, env_vars=data.get('Env', {})) @@ -454,6 +562,7 @@ def create_container(): return error_response(str(e), 500) @app.route('/containers//rename', methods=['POST']) +@app.route('/v1.52/containers//rename', methods=['POST']) @app.route('/v1.43/containers//rename', methods=['POST']) def rename_container(container_id): """POST /containers/{id}/rename - Rename container.""" @@ -468,6 +577,7 @@ def rename_container(container_id): return "", 204 @app.route('/containers//exec', methods=['POST']) +@app.route('/v1.52/containers//exec', methods=['POST']) @app.route('/v1.43/containers//exec', methods=['POST']) def exec_create(container_id): """POST /containers/{id}/exec - Create exec instance.""" @@ -483,12 +593,14 @@ def exec_create(container_id): return jsonify({"Id": exec_id}), 201 @app.route('/exec//start', methods=['POST']) +@app.route('/v1.52/exec//start', methods=['POST']) @app.route('/v1.43/exec//start', methods=['POST']) def exec_start(exec_id): """POST /exec/{id}/start - Start exec instance.""" return Response("", mimetype='text/plain') @app.route('/images/json', methods=['GET']) +@app.route('/v1.52/images/json', methods=['GET']) @app.route('/v1.43/images/json', methods=['GET']) def list_images(): """GET /images/json - List images.""" @@ -507,6 +619,7 @@ def list_images(): } for img in images]) @app.route('/images//json', methods=['GET']) +@app.route('/v1.52/images//json', methods=['GET']) @app.route('/v1.43/images//json', methods=['GET']) def inspect_image(image_id): """GET /images/{id}/json - Inspect image.""" @@ -528,12 +641,14 @@ def inspect_image(image_id): }) @app.route('/images/search', methods=['GET']) +@app.route('/v1.52/images/search', methods=['GET']) @app.route('/v1.43/images/search', methods=['GET']) def search_images(): """GET /images/search - Search images.""" return jsonify([]) @app.route('/images/create', methods=['POST']) +@app.route('/v1.52/images/create', methods=['POST']) @app.route('/v1.43/images/create', methods=['POST']) def pull_image(): """POST /images/create - Pull image.""" @@ -559,6 +674,7 @@ def pull_image(): return Response(f'{{"status":"Error pulling image: {str(e)}"}}\n', mimetype='application/json', status=500) @app.route('/images/', methods=['DELETE']) +@app.route('/v1.52/images/', methods=['DELETE']) @app.route('/v1.43/images/', methods=['DELETE']) def delete_image(image_id): """DELETE /images/{id} - Delete image.""" @@ -570,6 +686,7 @@ def delete_image(image_id): return error_response(str(e), 500) @app.route('/images//tag', methods=['POST']) +@app.route('/v1.52/images//tag', methods=['POST']) @app.route('/v1.43/images//tag', methods=['POST']) def tag_image(image_id): """POST /images/{id}/tag - Tag image.""" @@ -582,6 +699,7 @@ def tag_image(image_id): return "", 201 @app.route('/networks', methods=['GET']) +@app.route('/v1.52/networks', methods=['GET']) @app.route('/v1.43/networks', methods=['GET']) def list_networks(): """GET /networks - List networks.""" @@ -606,6 +724,7 @@ def list_networks(): }]) @app.route('/networks/', methods=['GET']) +@app.route('/v1.52/networks/', methods=['GET']) @app.route('/v1.43/networks/', methods=['GET']) def inspect_network(network_id): """GET /networks/{id} - Inspect network.""" @@ -613,6 +732,7 @@ def inspect_network(network_id): return jsonify(networks[0] if networks else {}) @app.route('/networks/create', methods=['POST']) +@app.route('/v1.52/networks/create', methods=['POST']) @app.route('/v1.43/networks/create', methods=['POST']) def create_network(): """POST /networks/create - Create network.""" @@ -628,12 +748,14 @@ def create_network(): }), 201 @app.route('/networks/', methods=['DELETE']) +@app.route('/v1.52/networks/', methods=['DELETE']) @app.route('/v1.43/networks/', methods=['DELETE']) def delete_network(network_id): """DELETE /networks/{id} - Delete network.""" return "", 204 @app.route('/volumes', methods=['GET']) +@app.route('/v1.52/volumes', methods=['GET']) @app.route('/v1.43/volumes', methods=['GET']) def list_volumes(): """GET /volumes - List volumes.""" @@ -643,6 +765,7 @@ def list_volumes(): }) @app.route('/volumes/create', methods=['POST']) +@app.route('/v1.52/volumes/create', methods=['POST']) @app.route('/v1.43/volumes/create', methods=['POST']) def create_volume(): """POST /volumes/create - Create volume.""" @@ -658,6 +781,7 @@ def create_volume(): }), 201 @app.route('/volumes/', methods=['GET']) +@app.route('/v1.52/volumes/', methods=['GET']) @app.route('/v1.43/volumes/', methods=['GET']) def inspect_volume(volume_name): """GET /volumes/{name} - Inspect volume.""" @@ -670,12 +794,14 @@ def inspect_volume(volume_name): }) @app.route('/volumes/', methods=['DELETE']) +@app.route('/v1.52/volumes/', methods=['DELETE']) @app.route('/v1.43/volumes/', methods=['DELETE']) def delete_volume(volume_name): """DELETE /volumes/{name} - Delete volume.""" return "", 204 @app.route('/events', methods=['GET']) +@app.route('/v1.52/events', methods=['GET']) @app.route('/v1.43/events', methods=['GET']) def stream_events(): """GET /events - Stream Docker events.""" @@ -692,6 +818,7 @@ def generate(): return Response(generate(), mimetype='application/json') @app.route('/system/df', methods=['GET']) +@app.route('/v1.52/system/df', methods=['GET']) @app.route('/v1.43/system/df', methods=['GET']) def system_df(): """GET /system/df - Disk usage.""" @@ -705,7 +832,7 @@ def system_df(): if __name__ == '__main__': print("=" * 60) - print("🐳 Complete Udocker Docker API Shim (v1.43)") + print("🐳 Complete Udocker Docker API Shim (v1.52)") print("📍 Listening on http://0.0.0.0:2375") print("✅ Features: Containers, Images, Networks, Volumes, Exec, Stats") print("=" * 60) diff --git a/core/db.py b/core/db.py index 752640d..d63250b 100644 --- a/core/db.py +++ b/core/db.py @@ -98,17 +98,43 @@ def create_container(self, cid, name, image, script=None, ports=None, env_vars=N int(datetime.now().timestamp()), 'created', json.dumps([]), - json.dumps(env_vars or {}) + json.dumps(env_vars or []) )) - # Add port bindings if provided + # Add port bindings if provided. Accept several formats: + # - {'tcp': [(host_port, container_port), ...]} + # - {'PORT_NAME': host_port} + # - {'tcp': (host_port, container_port)} if ports: - for proto, (host_port, container_port) in ports.items(): - c.execute(''' - INSERT INTO port_bindings - (container_id, host_port, container_port, protocol) - VALUES (?, ?, ?, ?) - ''', (cid, host_port, container_port, proto)) + for key, val in ports.items(): + # If list of tuples + if isinstance(val, (list, tuple)) and len(val) > 0 and isinstance(val[0], (list, tuple)): + for host_port, container_port in val: + proto = key if isinstance(key, str) else 'tcp' + c.execute(''' + INSERT INTO port_bindings + (container_id, host_port, container_port, protocol) + VALUES (?, ?, ?, ?) + ''', (cid, int(host_port), int(container_port), proto)) + else: + proto = 'tcp' + if isinstance(val, (list, tuple)) and len(val) == 2: + host_port, container_port = val + elif isinstance(val, int): + host_port = val + container_port = val + elif isinstance(val, dict): + host_port = int(val.get('HostPort') or val.get('host_port') or 0) + container_port = int(val.get('ContainerPort') or val.get('container_port') or host_port) + proto = val.get('Protocol') or val.get('protocol') or proto + else: + # Skip unsupported format + continue + c.execute(''' + INSERT INTO port_bindings + (container_id, host_port, container_port, protocol) + VALUES (?, ?, ?, ?) + ''', (cid, int(host_port), int(container_port), proto)) conn.commit() return True @@ -188,19 +214,46 @@ def append_log(self, cid, output): conn.commit() conn.close() - def get_logs(self, cid, tail=100): - """Get last N log lines.""" + def get_logs(self, cid, tail=100, timestamps=False, since=None): + """Get last N log lines, optionally prefixed with timestamps, and optionally filtered by timestamp. + + Args: + cid: container id + tail: number of lines to return + timestamps: if True, prefix each line with its ISO timestamp + since: if provided (int), return logs with timestamp > since + """ + rows = self.get_log_entries(cid, tail=tail, since=since) + lines = [] + for ts, out in rows: + if timestamps: + lines.append(f"{datetime.fromtimestamp(ts).isoformat()} {out}") + else: + lines.append(out) + return "\n".join(lines) + + def get_log_entries(self, cid, tail=100, since=None): + """Return a list of (timestamp, output) tuples ordered oldest->newest.""" conn = self.conn() c = conn.cursor() - c.execute(''' - SELECT output FROM container_logs - WHERE container_id = ? - ORDER BY timestamp DESC - LIMIT ? - ''', (cid, tail)) + if since is not None: + c.execute(''' + SELECT timestamp, output FROM container_logs + WHERE container_id = ? AND timestamp > ? + ORDER BY timestamp DESC + LIMIT ? + ''', (cid, int(since), tail)) + else: + c.execute(''' + SELECT timestamp, output FROM container_logs + WHERE container_id = ? + ORDER BY timestamp DESC + LIMIT ? + ''', (cid, tail)) rows = c.fetchall() conn.close() - return "\n".join([row[0] for row in reversed(rows)]) + # rows are newest->oldest due to ORDER BY DESC, reverse to oldest->newest + return list(reversed(rows)) # --- IMAGE OPS --- diff --git a/core/start_dashboard.sh b/core/start_dashboard.sh index 31557f0..1186d51 100644 --- a/core/start_dashboard.sh +++ b/core/start_dashboard.sh @@ -10,7 +10,7 @@ if ! python3 -c "import flask" >/dev/null 2>&1; then fi echo "------------ Udocker Docker API Server ----------" -echo "Starting Udocker Docker API (v1.43)" +echo "Starting Udocker Docker API (v1.52)" echo "Access at: http://localhost:2375" echo "API Compatible with Docker Engine API" echo "==================================================" diff --git a/tests/__pycache__/test_streams.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_streams.cpython-312-pytest-9.0.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7cc55ba549845eee11ae7a72579f939e86be60c4 GIT binary patch literal 8736 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 M=z~;*A2O`_51kuzv;Y7A literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_v1_52.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_v1_52.cpython-312-pytest-9.0.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..547035d0fa1df9e5bb29b61832a38049572a30e8 GIT binary patch literal 14494 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 Cy%Dqk literal 0 HcmV?d00001 diff --git a/tests/test_errors.py b/tests/test_errors.py new file mode 100644 index 0000000..7efd872 --- /dev/null +++ b/tests/test_errors.py @@ -0,0 +1,50 @@ +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +import pytest +pytest.importorskip('flask') +from core import dashboard + +app = dashboard.app + + +def test_pause_unpause_not_supported(): + client = app.test_client() + r = client.post('/v1.52/containers/create', json={"Image":"busybox:latest"}) + cid = r.get_json()['Id'] + + r2 = client.post(f'/v1.52/containers/{cid}/pause') + assert r2.status_code == 501 + + r3 = client.post(f'/v1.52/containers/{cid}/unpause') + assert r3.status_code == 501 + + +def test_delete_running_without_force_returns_409(): + client = app.test_client() + r = client.post('/v1.52/containers/create', json={"Image":"busybox:latest"}) + cid = r.get_json()['Id'] + + # Start container to mark state running + client.post(f'/v1.52/containers/{cid}/start') + + # Attempt delete without force + r2 = client.delete(f'/v1.52/containers/{cid}') + assert r2.status_code == 409 + + +def test_start_nonexistent_and_already_running(): + client = app.test_client() + # Non-existent + r = client.post('/v1.52/containers/notfound/start') + assert r.status_code == 404 + + # Create and start + r2 = client.post('/v1.52/containers/create', json={"Image":"busybox:latest"}) + cid = r2.get_json()['Id'] + r3 = client.post(f'/v1.52/containers/{cid}/start') + assert r3.status_code == 204 + + # Starting again should return 304 (container already started) + r4 = client.post(f'/v1.52/containers/{cid}/start') + assert r4.status_code in (304, 409, 204) # Accept either already-started or idempotent success \ No newline at end of file diff --git a/tests/test_follow_timeout.py b/tests/test_follow_timeout.py new file mode 100644 index 0000000..66d5bdb --- /dev/null +++ b/tests/test_follow_timeout.py @@ -0,0 +1,28 @@ +import time +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +import pytest +pytest.importorskip('flask') +from core import dashboard +from core.container_manager import db as cm_db + +app = dashboard.app + + +def test_follow_respects_idle_timeout(): + client = app.test_client() + r = client.post('/v1.52/containers/create', json={"Image":"busybox:latest"}) + cid = r.get_json()['Id'] + + # Add a log entry + cm_db.append_log(cid, 'Line A') + + # Request follow with small idle_timeout so stream returns quickly + r2 = client.get(f'/v1.52/containers/{cid}/logs?follow=1&idle_timeout=1') + assert r2.status_code == 200 + data = r2.get_data() + assert b'Line A' in data + # Response should return (stream ended due to idle timeout) + # Check that the response is finite and returned within a few seconds + assert len(data) > 0 \ No newline at end of file diff --git a/tests/test_streams.py b/tests/test_streams.py new file mode 100644 index 0000000..7d6b1d5 --- /dev/null +++ b/tests/test_streams.py @@ -0,0 +1,57 @@ +import re +import time +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) + +import pytest +pytest.importorskip('flask') + +from core import dashboard +from core.container_manager import db as cm_db + +app = dashboard.app + + +def test_logs_follow_and_multiplex(): + client = app.test_client() + r = client.post('/v1.52/containers/create', json={"Image":"busybox:latest"}) + cid = r.get_json()['Id'] + + # Add two logs + cm_db.append_log(cid, 'First follow') + cm_db.append_log(cid, 'Second follow') + + # Request follow=1 stream + r2 = client.get(f'/v1.52/containers/{cid}/logs?follow=1×tamps=0') + assert r2.status_code == 200 + data = r2.get_data() + # Since stdout default is true and stderr false, data is plain lines + assert b'First follow' in data + assert b'Second follow' in data + + # Request multiplexed style by setting stdout=0&stderr=1 + r3 = client.get(f'/v1.52/containers/{cid}/logs?follow=1&stdout=0&stderr=1') + assert r3.status_code == 200 + data3 = r3.get_data() + # Multiplexed data contains non-ASCII header bytes (stream type) + assert data3[:1] in (b"\x01", b"\x02") or len(data3) > 0 + + +def test_stats_streaming(): + client = app.test_client() + r = client.post('/v1.52/containers/create', json={"Image":"busybox:latest"}) + cid = r.get_json()['Id'] + + # Non-streaming + r2 = client.get(f'/v1.52/containers/{cid}/stats') + assert r2.status_code == 200 + j = r2.get_json() + assert 'memory_stats' in j + + # Streaming + r3 = client.get(f'/v1.52/containers/{cid}/stats?stream=1') + assert r3.status_code == 200 + data = r3.get_data(as_text=True) + # Should contain at least one JSON line + assert re.search(r"\{\s*\"read\"\s*:\s*\"", data) \ No newline at end of file diff --git a/tests/test_v1_52.py b/tests/test_v1_52.py new file mode 100644 index 0000000..32875eb --- /dev/null +++ b/tests/test_v1_52.py @@ -0,0 +1,97 @@ +import re +import json +import time +import sys +import os +import pytest +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +pytest.importorskip('flask') +from core import dashboard +from core.container_manager import db as cm_db + +app = dashboard.app + + +def test_ping(): + client = app.test_client() + r = client.get('/v1.52/_ping') + assert r.status_code == 200 + assert r.data == b'OK' + + +def test_version_api(): + client = app.test_client() + r = client.get('/v1.52/version') + assert r.status_code == 200 + j = r.get_json() + assert j.get('ApiVersion') == '1.52' + + +def test_create_container_and_inspect_env_ports(): + client = app.test_client() + payload = { + "Image": "portainer/portainer-ce:alpine", + "Hostname": "my-portainer", + "HostConfig": { + "PortBindings": { + "9000/tcp": [{"HostPort": "9000"}], + "9443/tcp": [{"HostPort": "9443"}] + } + }, + "Env": ["FOO=bar", "BAZ=1"] + } + + r = client.post('/v1.52/containers/create', json=payload) + assert r.status_code == 201 + j = r.get_json() + assert 'Id' in j + cid = j['Id'] + + # Inspect container + r2 = client.get(f'/v1.52/containers/{cid}/json') + assert r2.status_code == 200 + info = r2.get_json() + cfg_env = info.get('Config', {}).get('Env') + assert isinstance(cfg_env, list) + assert 'FOO=bar' in cfg_env + + # Check port mapping + ports = info.get('NetworkSettings', {}).get('Ports', {}) + assert ('9000/tcp' in ports) or ('9443/tcp' in ports) + + +def test_logs_with_timestamps(): + # Create a container and append log + client = app.test_client() + payload = {"Image": "busybox:latest"} + r = client.post('/v1.52/containers/create', json=payload) + cid = r.get_json()['Id'] + + # Append a log entry using the DB directly + cm_db.append_log(cid, 'Test log line') + + r2 = client.get(f'/v1.52/containers/{cid}/logs?tail=10×tamps=1') + assert r2.status_code == 200 + text = r2.get_data(as_text=True) + # Should contain an ISO timestamp followed by the log line + assert re.search(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}", text) + assert 'Test log line' in text + + +def test_logs_since_filtering(): + client = app.test_client() + r = client.post('/v1.52/containers/create', json={"Image":"busybox:latest"}) + cid = r.get_json()['Id'] + + # Add two log lines at different times + cm_db.append_log(cid, 'First line') + t0 = int(time.time()) + time.sleep(1) + cm_db.append_log(cid, 'Second line') + + # Request logs since t0 (should return only 'Second line') + r2 = client.get(f'/v1.52/containers/{cid}/logs?since={t0}×tamps=0') + assert r2.status_code == 200 + text = r2.get_data(as_text=True) + assert 'Second line' in text + assert 'First line' not in text