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/core/__pycache__/dashboard.cpython-312.pyc b/core/__pycache__/dashboard.cpython-312.pyc index e999f22..c63e3d3 100644 Binary files a/core/__pycache__/dashboard.cpython-312.pyc and b/core/__pycache__/dashboard.cpython-312.pyc differ diff --git a/core/__pycache__/models.cpython-312.pyc b/core/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000..b56930a Binary files /dev/null and b/core/__pycache__/models.cpython-312.pyc differ 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/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/__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 0000000..3525ff7 Binary files /dev/null and b/tests/__pycache__/test_api_errors_and_exec.cpython-312-pytest-9.0.1.pyc differ 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 0000000..7b517c2 Binary files /dev/null and b/tests/__pycache__/test_errors.cpython-312-pytest-9.0.1.pyc differ diff --git a/tests/__pycache__/test_follow_timeout.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_follow_timeout.cpython-312-pytest-9.0.1.pyc new file mode 100644 index 0000000..3dddb3c Binary files /dev/null and b/tests/__pycache__/test_follow_timeout.cpython-312-pytest-9.0.1.pyc differ 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 0000000..84d3c0d Binary files /dev/null and b/tests/__pycache__/test_heartbeat_multiplex.cpython-312-pytest-9.0.1.pyc differ diff --git a/tests/__pycache__/test_models.cpython-312-pytest-9.0.1.pyc b/tests/__pycache__/test_models.cpython-312-pytest-9.0.1.pyc new file mode 100644 index 0000000..0c8ba3e Binary files /dev/null and b/tests/__pycache__/test_models.cpython-312-pytest-9.0.1.pyc differ 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 diff --git a/tests/test_api_errors_and_exec.py b/tests/test_api_errors_and_exec.py new file mode 100644 index 0000000..629e215 --- /dev/null +++ b/tests/test_api_errors_and_exec.py @@ -0,0 +1,74 @@ +import sys +import os +import json +import pytest + +# Ensure core/ is on sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..', 'core'))) +import pytest +pytest.importorskip('flask') + +from dashboard import app +from container_manager import db as cdb + + +@pytest.fixture +def client(): + app.config['TESTING'] = True + with app.test_client() as c: + yield c + + +def create_container_via_api(client, name=None): + payload = {"Image": "alpine"} + resp = client.post('/v1.52/containers/create', json=payload) + assert resp.status_code == 201 + data = resp.get_json() + return data['Id'] + + +def test_missing_container_returns_404(client): + resp = client.get('/v1.52/containers/no-such-id/json') + assert resp.status_code == 404 + + +def test_delete_running_without_force_returns_409(client): + cid = create_container_via_api(client) + # Mark container as running in DB directly to avoid calling udocker + cdb.update_state(cid, 'running') + resp = client.delete(f'/v1.52/containers/{cid}') + assert resp.status_code == 409 + + +def test_pause_unpause_return_501(client): + cid = create_container_via_api(client) + resp = client.post(f'/v1.52/containers/{cid}/pause') + assert resp.status_code == 501 + resp = client.post(f'/v1.52/containers/{cid}/unpause') + assert resp.status_code == 501 + + +def test_exec_create_and_start(client): + cid = create_container_via_api(client) + resp = client.post(f'/v1.52/containers/{cid}/exec', json={'Cmd':['echo','hi']}) + assert resp.status_code == 201 + data = resp.get_json() + assert 'Id' in data + exec_id = data['Id'] + resp2 = client.post(f'/v1.52/exec/{exec_id}/start', json={'Detach': False, 'Tty': False}) + assert resp2.status_code in (200, 101, 204) + + +def test_tag_image_and_rename(client): + # Tag requires repo param + resp = client.post('/v1.52/images/some-image/tag') + assert resp.status_code == 400 + resp2 = client.post('/v1.52/images/some-image/tag?repo=myrepo&tag=v1') + assert resp2.status_code == 201 + + # Rename + cid = create_container_via_api(client) + resp = client.post(f'/v1.52/containers/{cid}/rename') + assert resp.status_code == 400 + resp2 = client.post(f'/v1.52/containers/{cid}/rename?name=newname') + assert resp2.status_code == 204 diff --git a/tests/test_heartbeat_multiplex.py b/tests/test_heartbeat_multiplex.py new file mode 100644 index 0000000..7382ea1 --- /dev/null +++ b/tests/test_heartbeat_multiplex.py @@ -0,0 +1,34 @@ +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_heartbeat_and_multiplex_params(): + client = app.test_client() + r = client.post('/v1.52/containers/create', json={"Image":"busybox:latest"}) + cid = r.get_json()['Id'] + + # Add one line + cm_db.append_log(cid, 'HB line') + + # heartbeat=0 should send an immediate heartbeat after entries (plain text) + r2 = client.get(f'/v1.52/containers/{cid}/logs?follow=1&heartbeat=0&multiplex=0&idle_timeout=1') + assert r2.status_code == 200 + data = r2.get_data() + assert b'HB line' in data + + # multiplex=1 should send multiplexed data (header bytes present) + cm_db.append_log(cid, 'Mux line') + r3 = client.get(f'/v1.52/containers/{cid}/logs?follow=1&multiplex=1&idle_timeout=1') + assert r3.status_code == 200 + data3 = r3.get_data() + # Multiplexed data has the 1-byte stream header at least once + assert len(data3) > 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