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 0000000..e999f22 Binary files /dev/null and b/core/__pycache__/dashboard.cpython-312.pyc differ diff --git a/core/container_manager.py b/core/container_manager.py index 7953f2e..073df9a 100644 --- a/core/container_manager.py +++ b/core/container_manager.py @@ -125,7 +125,7 @@ def inspect_container(cid): "Created": datetime.fromtimestamp(db_info['created_at']).isoformat() + "Z", "PortBindings": ports, "RestartCount": db_info['restart_count'], - "Env": json.loads(db_info['env_vars'] or '{}') + "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 '[]')) } @staticmethod diff --git a/core/dashboard.py b/core/dashboard.py index 9fe75bf..ef85587 100644 --- a/core/dashboard.py +++ b/core/dashboard.py @@ -52,7 +52,15 @@ 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 + base.update({ + "Created": datetime.fromtimestamp(container['created_at']).isoformat() + "Z", "Path": "sh", "Args": [], "State": { @@ -84,7 +92,7 @@ def container_to_json(container, inspect=False): "Tty": False, "OpenStdin": False, "StdinOnce": False, - "Env": json.loads(container['env_vars'] or '{}'), + "Env": env_list, "Cmd": ["sh"], "Image": container['image'], "Volumes": None, @@ -99,11 +107,13 @@ def container_to_json(container, inspect=False): return base @app.route('/_ping', methods=['GET']) +@app.route('/v1.52/_ping', methods=['GET']) @app.route('/v1.43/_ping', methods=['GET']) def ping(): return "OK", 200 @app.route('/version', methods=['GET']) +@app.route('/v1.52/version', methods=['GET']) @app.route('/v1.43/version', methods=['GET']) def version(): return jsonify({ @@ -111,10 +121,10 @@ def version(): "Components": [{ "Name": "Engine", "Version": "24.0.0-udocker-shim", - "Details": {"ApiVersion": "1.43", "Os": "android", "Arch": "arm64"} + "Details": {"ApiVersion": "1.52", "Os": "android", "Arch": "arm64"} }], "Version": "24.0.0", - "ApiVersion": "1.43", + "ApiVersion": "1.52", "MinAPIVersion": "1.12", "GitCommit": "udocker-shim", "GoVersion": "go1.20", @@ -125,6 +135,7 @@ def version(): }) @app.route('/info', methods=['GET']) +@app.route('/v1.52/info', methods=['GET']) @app.route('/v1.43/info', methods=['GET']) def info(): containers = db.list_containers() @@ -178,6 +189,7 @@ def info(): }) @app.route('/containers/json', methods=['GET']) +@app.route('/v1.52/containers/json', methods=['GET']) @app.route('/v1.43/containers/json', methods=['GET']) def list_containers(): """GET /containers/json - List containers.""" @@ -191,6 +203,7 @@ def list_containers(): return jsonify([container_to_json(c) for c in containers]) @app.route('/containers//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 0000000..7cc55ba Binary files /dev/null and b/tests/__pycache__/test_streams.cpython-312-pytest-9.0.1.pyc differ 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 0000000..547035d Binary files /dev/null and b/tests/__pycache__/test_v1_52.cpython-312-pytest-9.0.1.pyc differ 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