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 1/3] 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 From 2d4bab573b108cc372a35bcdac7aff84ae11c48a Mon Sep 17 00:00:00 2001 From: Automated Bot Date: Sun, 21 Dec 2025 09:28:04 +0000 Subject: [PATCH 2/3] chore(docs): add runner status marker and include runner setup in PR --- .github/workflows/ci.yml | 32 +++++++++++ QUICKSTART.md | 11 ++++ README.md | 29 ++++++++++ RUNNER_STATUS.md | 21 +++++++ docs/SDK-vs-shim.md | 33 +++++++++++ docs/release_ready.md | 53 ++++++++++++++++++ tests/integration/smoke_termux.sh | 93 +++++++++++++++++++++++++++++++ 7 files changed, 272 insertions(+) create mode 100644 RUNNER_STATUS.md create mode 100644 docs/SDK-vs-shim.md create mode 100644 docs/release_ready.md create mode 100644 tests/integration/smoke_termux.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c04f4e5..a860485 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,3 +21,35 @@ jobs: pip install flask pytest - name: Run tests run: pytest -q + + smoke-termux: + # This job is intended to run on a self-hosted Termux runner (Android device) + runs-on: [self-hosted, termux-android] + steps: + - uses: actions/checkout@v4 + - name: Make smoke script executable + run: chmod +x tests/integration/smoke_termux.sh + - name: Run Termux smoke test (self-hosted) + run: | + ./tests/integration/smoke_termux.sh + + smoke-ubuntu: + # Best-effort smoke test on ubuntu-latest. This tries to install udocker and run the same smoke script. + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + - name: Install udocker (best-effort) + run: | + python -m pip install --upgrade pip + pip install udocker || true + udocker --version || true + - name: Make smoke script executable + run: chmod +x tests/integration/smoke_termux.sh + - name: Run smoke test (best-effort) + run: | + ./tests/integration/smoke_termux.sh diff --git a/QUICKSTART.md b/QUICKSTART.md index 3242f79..81f6a03 100644 --- a/QUICKSTART.md +++ b/QUICKSTART.md @@ -120,6 +120,17 @@ sqlite3 udocker_state.db --- +## CI smoke test (optional) + +You can run an integration smoke test on an Android Termux device by registering it as a self-hosted runner (label it `termux-android`) and running the `smoke-termux` job in CI, or run it locally: + +```bash +chmod +x tests/integration/smoke_termux.sh +./tests/integration/smoke_termux.sh +``` + +--- + ## ✨ Features ✅ **40+ Docker API endpoints** diff --git a/README.md b/README.md index 15e0be3..0f648fc 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,17 @@ curl -X POST http://localhost:2375/v1.52/containers/create \ curl http://localhost:2375/v1.52/containers/CONTAINER_ID/logs ``` +Options: `tail`, `since` (unix seconds), `timestamps=1`, `follow=1` (stream), `multiplex=1|0` (force multiplex), `heartbeat` (seconds, keepalive), `idle_timeout` (seconds to close follow when idle). + +Examples: +```bash +# Stream logs with timestamps and follow +curl -N "http://localhost:2375/v1.52/containers/CONTAINER_ID/logs?follow=1×tamps=1" + +# Get logs since timestamp +curl "http://localhost:2375/v1.52/containers/CONTAINER_ID/logs?since=1710000000" +``` + ### Stop and Delete a Container ```bash curl -X POST http://localhost:2375/v1.52/containers/CONTAINER_ID/stop @@ -268,6 +279,24 @@ For issues: 3. Check database: `sqlite3 udocker_state.db` 4. Test endpoint: `curl http://localhost:2375/_ping` +## CI Self-hosted Termux Runner (smoke tests) + +If you want CI to run a smoke-test on an Android device running Termux, register that device as a **self-hosted runner** in your repository and add the label `termux-android` to it. The workflow includes a `smoke-termux` job which will execute `tests/integration/smoke_termux.sh` on that runner. + +Steps: + +1. On GitHub, go to your repository > Settings > Actions > Runners > Add runner and follow the registration steps for your Android device (Termux supports the runner binary via `chmod +x` and running the provided script). +2. When registering the runner, add the label `termux-android` (so the job matches `runs-on: [self-hosted, termux-android]`). +3. Ensure `udocker` is installed on the device and available in PATH. +4. Run the `smoke-termux` job from a PR or workflow run; it will start the dashboard (if not running), exercise container create/start/logs/stop/delete, and report success or failure. + +You can also run the test locally on the device: + +```bash +chmod +x tests/integration/smoke_termux.sh +./tests/integration/smoke_termux.sh +``` + --- **Created**: 2025-12-21 diff --git a/RUNNER_STATUS.md b/RUNNER_STATUS.md new file mode 100644 index 0000000..6a131d6 --- /dev/null +++ b/RUNNER_STATUS.md @@ -0,0 +1,21 @@ +# Runner Registration Status + +Repository: xeniosrahi/Termux-Udocker-API +Branch: v1.52 + +Runner Registered: false +Runner Label: termux-android +Runner Name: termux-android-1 (suggested) + +Notes: +- Use this file to mark whether a Termux self-hosted runner has been registered for CI smoke tests. +- To register the runner, follow the instructions in `README.md` > "CI Self-hosted Termux Runner (smoke tests)" or the `docs/SDK-vs-shim.md` runner section. +- After successful registration and a green smoke run, update `Runner Registered: true` and optionally record the runner name & timestamp. + +Example record after registration: + +Runner Registered: true +Registered At: 2025-12-21T12:34:56Z +Runner URL: https://github.com/xeniosrahi/Termux-Udocker-API/actions/runners +Runner Notes: udocker installed; tested smoke-termux job + diff --git a/docs/SDK-vs-shim.md b/docs/SDK-vs-shim.md new file mode 100644 index 0000000..20e55c9 --- /dev/null +++ b/docs/SDK-vs-shim.md @@ -0,0 +1,33 @@ +# SDK vs SHIM evaluation + +Summary +------- +This project currently acts as a compatibility shim between Docker Engine API callers (Portainer, Docker CLI) and `udocker` (a userspace runner for containers on Android Termux). We evaluated two approaches: + +- Using a Docker SDK (e.g., `docker` Python package / docker-py) +- Continuing with the current shim approach (subprocess calls to `udocker` + mapping layer) + +Recommendation +-------------- +**Keep the current shim approach for now**, and revisit the SDK if/when the environment if it supports a native Docker Engine or a higher-fidelity remote endpoint. + +Reasoning +--------- +- Udocker is not a Docker Engine replacement; it emulates container execution but does not expose a Docker socket or full engine API that the `docker` SDK expects. The SDK assumes a Docker daemon (socket or TCP API) with certain behaviors that udocker does not guarantee. +- The shim approach currently works reliably on Termux: it maps Docker API shapes to udocker runtime operations and we already have extensive tests and compatibility shims (ports, env normalization, logs streaming, etc.). +- Migrating to `docker` SDK would require either: (a) implementing an adapter that exposes udocker as a Docker Engine to the SDK or (b) installing/running a real Docker daemon on the host — both approaches add non-trivial effort and move away from the project's core goal of enabling container management on unprivileged Android devices. + +When to reconsider +------------------ +- If you switch from Udocker to an environment with a real Docker Engine (rooted device, remote Docker host), then the `docker` SDK is likely a better fit. +- If a future udocker releases a stable Docker Engine API-compatible bridge, re-evaluate migrating to the SDK for developer ergonomics and maintenance. + +Suggested next steps (if keeping shim) +------------------------------------- +- Harden the udocker shim: add integration smoke tests (Termux self-hosted runner) and broaden tests for exec, tagging, rename, and more edge cases. +- Keep compatibility shims documented and well-tested; prefer small refactors focused on robustness rather than large architectural change. + +Acceptance criteria for this decision +------------------------------------ +- A concise document (this file) that summarizes pros/cons and a concrete recommendation. +- A follow-up task to add the Termux smoke-test CI job and runner setup instructions (implemented in this PR). diff --git a/docs/release_ready.md b/docs/release_ready.md new file mode 100644 index 0000000..517887e --- /dev/null +++ b/docs/release_ready.md @@ -0,0 +1,53 @@ +# Release readiness checklist — provide server to Portainer + +Goal: ship the Udocker Docker API shim so it can be used by Portainer (HTTP/TCP endpoint). + +Preconditions +-------------- +- The server exposes the Docker-compatible HTTP API on port 2375 (or configured port). +- Portainer expects a Docker Engine API endpoint reachable via TCP (no TLS by default). For production, TLS and auth should be added in front. +- Udocker is installed on the target device and in PATH for the service user. + +Checklist +--------- +- [ ] Functional tests: All unit tests must pass in CI (including integration smoke tests on a Termux runner). +- [ ] API parity: Confirm the endpoints Portainer needs are implemented (containers list, inspect, start/stop, logs, images list, pull, tag, delete). Document missing features. +- [ ] Security: Do NOT expose port 2375 publicly. Recommend using SSH tunneling or reverse proxy with TLS + auth. +- [ ] Resource constraints: Portainer may query stats; our `stats` endpoint returns stubbed data — document this limitation. +- [ ] Long-running streams: Ensure the host can maintain keepalive for `logs?follow=1` — optional heartbeat parameter available. +- [ ] Port mapping: Confirm `HostConfig.PortBindings` parsing behavior matches Portainer expectations (we normalize to host ports stored in DB). +- [ ] Volumes & Networks: Portainer may show limited volume/network features — list them as 'stubbed'. +- [ ] Performance: For many containers, DB-backed listing may need optimization (indexing). Consider adding indexes to `containers.created_at` and `container_logs.container_id`. + +Runbook for deploying to Portainer +---------------------------------- +1. Start the server on the host that Portainer can reach (example on phone behind SSH tunnel): + +```bash +# on device +./start_dashboard.sh + +# on your machine (forward local port 2375 to device) +ssh -L 2375:localhost:2375 user@device-ip +``` + +2. In Portainer, add a new environment with URL `http://:2375`. +3. Use Portainer UI to inspect containers. Note some actions may be unsupported; consult `README.md`. + +Security recommendation +----------------------- +- Add a TLS reverse proxy (nginx/caddy) or enable SSH tunnel when using Portainer. +- Consider adding a simple API key middleware if exposing within a trusted network. + +Acceptance criteria +------------------- +- A CI run that executes unit tests and the Termux smoke test (on a self-hosted runner) without errors. +- Basic Portainer flows (list containers, inspect, start/stop, logs) work in manual verification. + +Known limitations +----------------- +- Pause/unpause unsupported +- Stats are stubbed +- Advanced networking and volumes are simplified +- No authentication built-in — secure before exposing publicly + diff --git a/tests/integration/smoke_termux.sh b/tests/integration/smoke_termux.sh new file mode 100644 index 0000000..41b7c79 --- /dev/null +++ b/tests/integration/smoke_termux.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Smoke test for Termux (Android) devices running Udocker +# Intended to run on a self-hosted runner on an Android Termux device + +API_URL="http://localhost:2375/v1.52" +LOG=/tmp/udocker_smoke.log +PIDFILE=/tmp/udocker_dashboard.pid + +echo "Starting Termux smoke test..." | tee "$LOG" + +# Helpers +function fail() { + echo "FAIL: $*" | tee -a "$LOG" + if [ -f "$PIDFILE" ]; then + kill "$(cat $PIDFILE)" || true + fi + exit 1 +} + +# check prerequisites +command -v curl >/dev/null 2>&1 || fail "curl is required" +command -v udocker >/dev/null 2>&1 || fail "udocker is required on Termux to run this smoke test" + +# Start the server if not already running +if ! curl -sSf http://localhost:2375/_ping >/dev/null 2>&1; then + echo "Starting dashboard..." | tee -a "$LOG" + ./start_dashboard.sh > /tmp/dashboard.out 2>&1 & + echo $! > "$PIDFILE" + # wait for server to be ready + for i in {1..30}; do + if curl -sSf http://localhost:2375/_ping >/dev/null 2>&1; then + echo "Server responded to /_ping" | tee -a "$LOG" + break + fi + echo "Waiting for server... ($i)" | tee -a "$LOG" + sleep 1 + done + if ! curl -sSf http://localhost:2375/_ping >/dev/null 2>&1; then + fail "Dashboard did not start in time. Check /tmp/dashboard.out" + fi +else + echo "Server already running" | tee -a "$LOG" +fi + +# Basic health +curl -sSf http://localhost:2375/_ping | tee -a "$LOG" || fail "/_ping failed" + +# Unique name for container +NAME="smoke-$(date +%s)" + +# Create a container that uses a tiny image (alpine) — pull may happen +echo "Creating container $NAME" | tee -a "$LOG" +CREATE_RESP=$(curl -sSf -X POST "$API_URL/containers/create" -H "Content-Type: application/json" -d '{"Image":"alpine","Hostname":"smoke","Cmd":["sh","-c","echo smoke-test; sleep 1"], "HostConfig":{}}') +ID=$(echo "$CREATE_RESP" | sed -n 's/.*"Id"[[:space:]]*:[[:space:]]*"\([0-9a-fA-F]\+\)".*/\1/p' || true) +if [ -z "$ID" ]; then + # try to parse Id field more simply + ID=$(echo "$CREATE_RESP" | awk -F'"' '/Id/{print $4; exit}') +fi +[ -n "$ID" ] || fail "Create returned no Id: $CREATE_RESP" + +echo "Created container ID=$ID" | tee -a "$LOG" + +# Start the container +curl -sSf -X POST "$API_URL/containers/$ID/start" || fail "Start failed" + +# Wait briefly for it to finish +sleep 2 + +# Inspect +INSPECT=$(curl -sSf "$API_URL/containers/$ID/json") || fail "Inspect failed" +echo "$INSPECT" | tee -a "$LOG" + +# Logs +echo "Getting logs" | tee -a "$LOG" +curl -sSf "$API_URL/containers/$ID/logs?stdout=1&stderr=1×tamps=1&tail=10" | tee -a "$LOG" || true + +# Stop (best-effort) then remove +curl -sSf -X POST "$API_URL/containers/$ID/stop" || true +curl -sSf -X DELETE "$API_URL/containers/$ID?force=1" || true + +# Final health check +curl -sSf http://localhost:2375/_ping || fail "Final /_ping failed" + +# Cleanup +if [ -f "$PIDFILE" ]; then + kill "$(cat $PIDFILE)" || true + rm -f "$PIDFILE" +fi + +echo "SMOKE TEST PASSED" | tee -a "$LOG" +exit 0 From a4c1a328dd212ffe151372b6469dcc19b1f90ab5 Mon Sep 17 00:00:00 2001 From: Automated Bot Date: Sun, 21 Dec 2025 09:29:20 +0000 Subject: [PATCH 3/3] v1.52 --- core/__pycache__/dashboard.cpython-312.pyc | Bin 39417 -> 44450 bytes core/__pycache__/models.cpython-312.pyc | Bin 0 -> 3647 bytes core/container_manager.py | 4 +- core/dashboard.py | 126 ++++++++++++------ core/db.py | 3 +- core/models.py | 93 +++++++++++++ ...rors_and_exec.cpython-312-pytest-9.0.1.pyc | Bin 0 -> 10648 bytes .../test_errors.cpython-312-pytest-9.0.1.pyc | Bin 0 -> 6174 bytes ...ollow_timeout.cpython-312-pytest-9.0.1.pyc | Bin 0 -> 3738 bytes ...eat_multiplex.cpython-312-pytest-9.0.1.pyc | Bin 0 -> 5714 bytes .../test_models.cpython-312-pytest-9.0.1.pyc | Bin 0 -> 8231 bytes tests/test_api_errors_and_exec.py | 74 ++++++++++ tests/test_heartbeat_multiplex.py | 34 +++++ tests/test_models.py | 40 ++++++ 14 files changed, 332 insertions(+), 42 deletions(-) create mode 100644 core/__pycache__/models.cpython-312.pyc create mode 100644 core/models.py create mode 100644 tests/__pycache__/test_api_errors_and_exec.cpython-312-pytest-9.0.1.pyc create mode 100644 tests/__pycache__/test_errors.cpython-312-pytest-9.0.1.pyc create mode 100644 tests/__pycache__/test_follow_timeout.cpython-312-pytest-9.0.1.pyc create mode 100644 tests/__pycache__/test_heartbeat_multiplex.cpython-312-pytest-9.0.1.pyc create mode 100644 tests/__pycache__/test_models.cpython-312-pytest-9.0.1.pyc create mode 100644 tests/test_api_errors_and_exec.py create mode 100644 tests/test_heartbeat_multiplex.py create mode 100644 tests/test_models.py diff --git a/core/__pycache__/dashboard.cpython-312.pyc b/core/__pycache__/dashboard.cpython-312.pyc index e999f22ab7dacd363d725eeade9865609b39c5dd..c63e3d30a159d32ea7ce893dfb7c962ccd1eb7e1 100644 GIT binary patch delta 11995 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 literal 0 HcmV?d00001 diff --git a/core/container_manager.py b/core/container_manager.py index 073df9a..844ad7a 100644 --- a/core/container_manager.py +++ b/core/container_manager.py @@ -5,6 +5,8 @@ import threading from datetime import datetime from db import ContainerDB +import json +import models db = ContainerDB() @@ -125,7 +127,7 @@ def inspect_container(cid): "Created": datetime.fromtimestamp(db_info['created_at']).isoformat() + "Z", "PortBindings": ports, "RestartCount": db_info['restart_count'], - "Env": (lambda e: [f"{k}={v}" for k, v in e.items()] if isinstance(e, dict) else e)(json.loads(db_info['env_vars'] or '[]')) + "Env": models.normalize_env(json.loads(db_info['env_vars'] or '[]')) } @staticmethod diff --git a/core/dashboard.py b/core/dashboard.py index ef85587..1a7b40d 100644 --- a/core/dashboard.py +++ b/core/dashboard.py @@ -6,6 +6,7 @@ import threading from flask import Flask, jsonify, request, Response from container_manager import ContainerManager, db +import models from datetime import datetime import sqlite3 @@ -54,10 +55,7 @@ def container_to_json(container, inspect=False): if inspect: # Normalize Env to a list of strings "KEY=VALUE" env_raw = json.loads(container['env_vars'] or '[]') - if isinstance(env_raw, dict): - env_list = [f"{k}={v}" for k, v in env_raw.items()] - else: - env_list = env_raw + env_list = models.normalize_env(env_raw) base.update({ "Created": datetime.fromtimestamp(container['created_at']).isoformat() + "Z", @@ -259,25 +257,42 @@ def mux_header(stream_type, size): def generate_stream(): sent = 0 last_ts = since_val or 0 - timeout = 5 # seconds to poll for new logs before closing - start = time.time() + + # Heartbeat and multiplex control + heartbeat = int(request.args.get('heartbeat', '15')) # seconds + multiplex_param = request.args.get('multiplex') # '1'|'0'|None + auto_multiplex = stdout and stderr + def should_multiplex(): + if multiplex_param is None: + return auto_multiplex + return multiplex_param == '1' # 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 + try: + line = out + if timestamps: + line = f"{datetime.fromtimestamp(ts).isoformat()} {out}" + if not should_multiplex(): + yield (line + "\n").encode('utf-8') + else: + stream_type = 1 if stdout else 2 + payload = line.encode('utf-8') + b"\n" + try: + yield mux_header(stream_type, len(payload)) + payload + except Exception: + # fall back to plain payload on send error + yield payload + last_ts = max(last_ts, ts) + sent += 1 + except GeneratorExit: + return + except BrokenPipeError: + return + except Exception: + # ignore and continue + continue # Follow: keep streaming until idle_timeout expires or generator is closed by client idle_timeout = int(request.args.get('idle_timeout', '300')) # seconds @@ -290,18 +305,48 @@ def generate_stream(): 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 + try: + line = out + if timestamps: + line = f"{datetime.fromtimestamp(ts).isoformat()} {out}" + if not should_multiplex(): + yield (line + "\n").encode('utf-8') + else: + stream_type = 1 if stdout else 2 + payload = line.encode('utf-8') + b"\n" + try: + yield mux_header(stream_type, len(payload)) + payload + except Exception: + yield payload + last_ts = max(last_ts, ts) + last_activity = time.time() + pushed = True + except GeneratorExit: + return + except BrokenPipeError: + return + except Exception: + continue + + # send heartbeat if requested and no data pushed + if not pushed and heartbeat and (time.time() - last_activity) >= heartbeat: + try: + hb = b"\n" + if should_multiplex(): + try: + yield mux_header(1, len(hb)) + hb + except Exception: + yield hb + else: + yield hb + last_activity = time.time() + except GeneratorExit: + return + except BrokenPipeError: + return + except Exception: + # ignore heartbeat send errors + pass # if no new data for idle_timeout, exit if not pushed and (time.time() - last_activity) > idle_timeout: @@ -313,7 +358,8 @@ def generate_stream(): # On any other exceptions, stop streaming return - return Response(generate_stream(), mimetype='application/octet-stream') + headers = {'Transfer-Encoding': 'chunked'} + return Response(generate_stream(), mimetype='application/octet-stream', headers=headers) @app.route('/containers//stats', methods=['GET']) @app.route('/v1.52/containers//stats', methods=['GET']) @@ -543,16 +589,16 @@ 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' - container_port_num = int(container_port.split('/')[0]) - if bindings: - host_port = int(bindings[0].get('HostPort', 0)) - ports.setdefault(proto, []).append((host_port, container_port_num)) + ports = models.normalize_port_bindings(data.get('HostConfig', {}).get('PortBindings')) try: + # Basic payload validation + try: + models.validate_container_create_payload(data) + except Exception: + # allow create to proceed with best-effort defaults if payload is missing Image + pass + db.create_container(cid, name, image, ports=ports, env_vars=data.get('Env', {})) return jsonify({ "Id": cid, diff --git a/core/db.py b/core/db.py index d63250b..7229bf4 100644 --- a/core/db.py +++ b/core/db.py @@ -2,6 +2,7 @@ import json from datetime import datetime import os +from models import normalize_env DB_PATH = 'udocker_state.db' @@ -98,7 +99,7 @@ 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(normalize_env(env_vars)) )) # Add port bindings if provided. Accept several formats: diff --git a/core/models.py b/core/models.py new file mode 100644 index 0000000..0186c09 --- /dev/null +++ b/core/models.py @@ -0,0 +1,93 @@ +import json +from typing import List, Dict, Tuple, Any + + +def normalize_env(env: Any) -> List[str]: + """Normalize environment variables to a list of "KEY=VALUE" strings. + + Accepts: None, dict, list, or string. + Returns: list of strings. + """ + if env is None: + return [] + if isinstance(env, dict): + return [f"{k}={v}" for k, v in env.items()] + if isinstance(env, list): + return [str(e) for e in env] + if isinstance(env, str): + # splitlines is safe for both single-line and multi-line representations + return [s for s in env.splitlines() if s] + # Fallback: coerce to single-element list + return [str(env)] + + +def normalize_port_bindings(port_bindings: Any) -> Dict[str, List[Tuple[int, int]]]: + """Normalize Docker-style HostConfig.PortBindings into a mapping: + {protocol: [(host_port, container_port), ...]} + + Expected input like: {"80/tcp":[{"HostPort":"8080"}], "53/udp":[{"HostPort":"53"}]} + """ + out: Dict[str, List[Tuple[int, int]]] = {} + if not port_bindings: + return out + + # If caller provided a raw JSON string, try to parse + if isinstance(port_bindings, str): + try: + port_bindings = json.loads(port_bindings) + except Exception: + return out + + if not isinstance(port_bindings, dict): + return out + + for container_port, bindings in port_bindings.items(): + if '/' in container_port: + port_str, proto = container_port.split('/', 1) + else: + port_str = container_port + proto = 'tcp' + try: + container_port_num = int(port_str) + except Exception: + continue + + if not bindings: + continue + + # bindings may be a list of dicts or other shapes + for b in bindings: + host_port = None + if isinstance(b, dict): + hp = b.get('HostPort') or b.get('host_port') or b.get('HostPort') + try: + host_port = int(hp) + except Exception: + host_port = None + elif isinstance(b, (list, tuple)) and len(b) >= 1: + try: + host_port = int(b[0]) + except Exception: + host_port = None + else: + try: + host_port = int(b) + except Exception: + host_port = None + + if host_port: + out.setdefault(proto, []).append((host_port, container_port_num)) + + return out + + +def validate_container_create_payload(data: Any) -> bool: + """Basic validation for container create payload. + + Raises ValueError on invalid payload. + """ + if not isinstance(data, dict): + raise ValueError("payload must be an object") + if not data.get('Image'): + raise ValueError("Image is required") + return True diff --git a/tests/__pycache__/test_api_errors_and_exec.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_api_errors_and_exec.cpython-312-pytest-9.0.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3525ff710ce66507694e14bb2dab87763543f6f5 GIT binary patch literal 10648 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 BaDe~- literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_errors.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_errors.cpython-312-pytest-9.0.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b517c2b396a212aa228ee019092755f5e6be7ae GIT binary patch literal 6174 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_heartbeat_multiplex.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_heartbeat_multiplex.cpython-312-pytest-9.0.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..84d3c0d55fc52a47b365939cd133381d56a4ee53 GIT binary patch literal 5714 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 0 + # We expect to see the header byte 0x01 or 0x02 present + assert b"\x01" in data3 or b"\x02" in data3 \ No newline at end of file diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..547d011 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,40 @@ +import sys +import os +import pytest +# Ensure core/ is on sys.path so `models` module can be imported during tests +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'core'))) +import models as m + + +def test_normalize_env_dict(): + inp = {'A':'1','B':'2'} + out = m.normalize_env(inp) + assert 'A=1' in out and 'B=2' in out + + +def test_normalize_env_list(): + inp = ['X=1','Y=2'] + out = m.normalize_env(inp) + assert out == inp + + +def test_normalize_env_str(): + inp = 'A=1\nB=2' + out = m.normalize_env(inp) + assert 'A=1' in out and 'B=2' in out + + +def test_normalize_port_bindings_basic(): + inp = {'80/tcp':[{'HostPort':'8080'}], '53/udp':[{'HostPort':'53'}]} + out = m.normalize_port_bindings(inp) + assert 'tcp' in out and 'udp' in out + assert (8080,80) in out['tcp'] + assert (53,53) in out['udp'] + + +def test_validate_container_create_payload(): + with pytest.raises(ValueError): + m.validate_container_create_payload(None) + with pytest.raises(ValueError): + m.validate_container_create_payload({}) + assert m.validate_container_create_payload({'Image':'alpine'}) is True