From 2d4bab573b108cc372a35bcdac7aff84ae11c48a Mon Sep 17 00:00:00 2001 From: Automated Bot Date: Sun, 21 Dec 2025 09:28:04 +0000 Subject: [PATCH 1/2] 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 2/2] 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 zcmb_?3v^RgcIf@+>Mu*SEX%fRS^itL`~w39JNyj^1_A++Ku8?nD~zygnIoBxBag_0 z1e?$TIUzBD6qrsLX!72WDtW`2OkbHvGD9G=Rz$`^VNFAuw9TZM+Jv-|WZul%`^u6o zG@W+6*UQrOzWaC1*=L`#_c@oZBHZ^r6tVO-7ahob{PDnK;Iy@?^ilq7K7s$`OTq%qFw=vZ&n0ZpszUh-&z2@#Ji; zr&F!wBOjRf6#7jXrKPM(u%=|Fhs|)Fmf^2~RDN3OOBu(;Wi%FXE}mr$IRdPm0jOonF*F2SCb z+8|_XJto6XgH%ac>Ju49r_5+HD_w#kEj1|P9GBrg0jbiol%UG!*U9j;Dwj}}mO3iq zyeY$PfK+)}s#2Y?)hWYws9i!uTIvlM=R+CJYFt8PTB<{nvDGWXkAPHFTIvHC=NlPb zs&xs~X(^93W9w-d{u)Ttq@});acsJb#v+|daHgf6lyP2>;ZcyPO-oty8T~dH?$*16 zy0p|wGR|cgJ_S;)wA3s^#@0hJ{BeUzs8371BjbD~!?UwoLPJ_=LsrJtV>0|SNHwOV zK9O;B#*9X@(Iqscr3Pi3<1+jwAl00f5=Mq^Q~OPHIMdQ!%DMTSQ~YF=8(V#(;Y$#A#DCA6ibUXpPx%kU|S ztE=6UEuQSHVHk23?C5uir&2pQ%Si91?|A>-9q)g7$Gc1)+8@TfKXNFqfMt#Jto21U zhc3@=<60E-MsFGY$fl0`&wMk>4+J&zZcKaFAEc@NSf>JH3op z3vV(V%8GTO8K6so^(6-4>!S@FG3&Ak>zb%_ zP0V`V_=X3g)(0b>l-*<-H?VaU?JcHGqnq5sGJ4bA&aZB+<5W4?uWmZ{ysUr@T8dcj zHkp3X;<(#VM9r0t2_c~MMrdUx%YKLcZPnfETv}6Iq+Ns=!=#z9$btBtH zrv}XQ$JH9)86fV6T&j-nN;mRX&?o9HfbLS)a^)c;jM5`63mc{{yE?$|=dRfWBS20l z2He4I2?ZJK?{;$JO-VJ0_XNEi^UFYijzMT|hpeo90xSt^$$ZbS1m>0X;OQ zMEfgv{{Vi0ge)%!L3wflAZ^>mNDSEo%azXLiDnMn`K&E^j15jCq*tYJ395?hGfuEBL)M09B@Oo&Yh z!3x(LP@|$nE2>_B-r$pLewYjD=-zva8b;HLcbE_H0eukNC_SQ1)TflzF(EEc1uEy9 zMd49qR5z+0RljO@1?L8yVMWlK#26-|5VKx2W)wx!KIVXGgc(tds7Ev-+7aD|eyd8% z-lrZG#2nG=Wrvj+ZH|E*Z`zcY8&bY%c?IVeo^^~fZ|EPEcdVyN=aq#W>jnn~{3IBd z=LiHv|6tICe?5e_95BN^fZje&An5KJ2mr_1-|cZZw)%ZO|1Ouq-7f;EZ@}kq^bPug zUO;wx`+FL+S_mV@YJbo(&#}x;rseNyb_BP10}e@{&jSGz^!iu!LvzIA?h80THxLAM zhtJ;=kZcb?I|0WQ;`MCx?Ex#q(eK&i@Il-K95u57&IYNSgfiK2^7nLLUU?ZtTSAa} zx;mjuc9mQMB8dSw%aSSjC-ZE}x~FFmXbdC35B>hdg8-gqg3~7um;=*2+XE|2*s>?+2@u>b66&D8OL}#330-pBUBNvA zo`fO*TU-FUG`;>3+P=ZvUE4fvn2MpY%yL8DG_?Et-ELoCeuE?vmW2Kf=mu&5>}5VS z<{s`n{BX=ze###;&iXNbe(9T!Zf?s&4k|< zzfdyFQPqCqep4sIf=5Jlm>D%sXBBps&3Gut(FOa9=pR2~p)c;y=~GJvmPMLTsYp=| zm_{wTSpunPH1~jU#5iKw%E2K|J^LOwkn%Y5B0sExHNdCVK)@POiE5lGI#6gS(WJWc z9QQoSGW|0~Erb)bu+nwI>W~U$!f6>%k9xxz4b(5AIU#ir_burOrDamW8!BkwglTaUg1Cagddonbb^$vZOeWI@hlVWlcxKzneAahV9NSgr~cp z?Olp9v4=U#9=yMt3Ff7yyn-QWmI-N+MmHk={4QoUzmeI+f*j0W@&P#o%WQ-tyoU)E zeJfRNQ<6Q*9Spl=#^eGjsZnW68<~Tj$fW-Nnq&_?E3=gsmh}{y%z7@>mU|F#McQ7%m`1w>+yv4k z8r{2Bs?`#vz&8J&Pt1r9E&7i#Mvn)IHh0j|mCQbwR3bMK#zd}xBp5&+|7I@}H`>RI zRdH+OxT!L3E<4p7H9N=kPAP$qFF@vN*6}r~{aP3dZmn-6cAyZ9^e-@xY7=^|=<{@8 zAp!M8f}h#cwEiD~0nbMZJ{92)l&`;rKV%BPGG}&J%9aGPGWlx+e}g~`AYttB1iK&! z!f_x7bwPk=kmWDb-v8GtIIKf(98cP9+XlcV>Oj@{m1!nm0D zdp-SYyaS#NLi~j2QAr5+!XD3nGar&NG?GyH+<{R=7> zqJ;bw^;HAzJwCr%Bsvr|NZRi1pm(P?xF?~50w&eXwqR=6Yf2 zN!KygL}6{Tu=Y$_tZ>eVF%v z#T^&cUI<+9jN4WoUV6=1ebwrW+e%JV9BZ2>Y={;%oY@8K$8zV~sIEUkq`@F-;Cpm?s>Y5z}@p||9>WD994==gU6Sc2;KY!INHDj}n^_(KH z!rH^CxG8sxJ)$|Hm@I{U-fCg;Z71fOY(LgMVXKSU>dve_-x0Gdj#-zC=&$D&ozxuD z9M_E)Cb>3^Ii8bu-IV*h_K0?D=Cj7@cE^OhDQa&zS9D>?#a&nJ4;&KQ}D`~u1(i|_ZK2veJZK7;;v~2ddU9qwSv7!Yx8fTp? zJ>NXm8Lz5+x%YJMx$0Qeyz?8PRg1^K8+>uN;aXMGIoprBFI2zji&b@A?1@%wc;B%B z=Dob?RL>a_E1NNf z0Q=^amdP!>$*=}%!jyO2T$VwLK-5t1ccy$0G!%RTb6HUj+~S6KRsHwFhDjb!R4;M@ z)l!nkw_mrx)TGb~L`xGeFFgGH=2q^sx-(u?^L;gHJ7wC}GTIUf+MK2MMt%|YviO@@ zS&XR|NE-7Eea>}b{>|_-4s4D2w}G9o7N1;n*D5FbQ+`p!iZ<@88taN`?yUvcfWK{8 z0E~A`^H-UepBWcbbhdCmZ?Sfkau;=FfM2Y40OQ>(&B}V_-F)lHLgwAZ0>Ce+i-3Nq zf(7MEl^o*Lh83;Cr51igTbR2vhsBpxc;PO!X*%t~Wd#qSmsM)SjS`+C;e`@j%p%<` z;iZPoX5n%J2hCq@Vi9lV5pU6~vqGyq7 zK$}-gnpIW&l>!cBY-+^IB|Kco;!72dW+;0-arXzddPsc-rWQD$CRE+q2K#$G;!wVO zV8G|?#)E8sqrW@o3Dzg`DkQXjh21%!N}k{XPVJr9Ogay$yaCbM;|)r=jij$c=(Tye zO>hEZoCZt>$-GEzBIZY{3OogpGRzhg2ryZYQpCP3O<+m!XfjPKlpb2rmX}il;#hG6 z8Ugv;UgnmGE^BMGW*yvhC^QM^{ehi=z6X zu?J%M%Do-e?B!>K9~jOWUNt_;AMSWodw%`id#Jn3#(!+ep;HU(8>f^EMs@a~$3`C; zYd@2FX6Qn}Rb%If;=0i~rZ^S4YHS%-x7<{6!Up!=)LU7>e3L;qxrS#<<)`i$&uZAa z{JPG3Cfi-O$`t# zUWPe^U?EHX7wuT^+bk!atigT`dPKU^44eDhy;xzogMqFk7Q`}iMCGbUAkTnWz z@#2#3NA(RCxM=Z`ctvHr#1U_7y0Bw>^TW~dN2WAFVb)|8Q{p05wtkPu*Z>+z5t z;3Wic5U%;8GMHFE4sNN~F0vZu0*@Px>Enie`sPZS#-YmF=XIOxdP**L8m-&~R(>ND zlpk9P$C~Mbn1vmO+3R_Q&u>4nJzCllE1ez7n{&P*nm7M_)BJSFbksNndD`(+0C&YF z7AY5LSh{whIs7HOI@NbJ`i@nH~PJ!5A@-V(WLa(l@T7gE18^CI({##As8B7X3vIRxE5r_!3B1n&q^w4Fi zDujL@CL;b-p|qE#?yX{bY5tn>I;sDvI~zf(2_C+g5cx>9u5oC00q5873k*oEKfmT_ zR-YcykbMokv96f@4iy)W1s{#gG^wiq&RV34Qt#3G$$*4;DA5Bpjqq7 zl*2&Na*^5Vv$*`UVUqSfF#w-FdVfJ_rh$w%Fi2smmC6A>ec}EZ?c*Sxjm?fq%|ZmS zsZjZ&2a9yr12Tl*1N!=dW!fhIg&ic-OZCQMTtT`Q2?1``yFKs+?v*{{3jNVTyTb#h zyaNFNu$}}El_HQMedG_#@0qVrGN2=m7eT!no>LButK^dHPioH zXorhY9sT{I?P17{sjLqlVzBG*z~ycFsxHJsuFX2ZrL2g?t=u{W?%D7FE@%!7UjUf~6_xV0j9|+v|qpi)c!9qMO?YX?|Gvj<9a}?=CiF)|pK&x#xsCK_{V2 z`>$&?ISLfgh-4L%rv3h1{R#D;=;0DSr`Vz^SBImrzbQ$+GQCXDwn3B}-`S4;>9TgcnU#F{plJ)l5f64uPe zTl~V*wPm{9L@C^8ctyO0$_$|!vLz5wmnYIfmu`Ga)10&@QP9aPiWPVVFKu;BWkLbQ z+@t_0h1fFR4WA{YVM)=03BVye$@n>n{y?w<1%W} zCN8n+7*heB=CW_-O?ZOK{^nK=qqorTZu{bP=*e3a%hC$wt=hRuXEJYBXqGndZ`X5( zH?oK~@rcjVblCW}+jBee_;*Y!;P2!K0EbHHYY*7yDUY5$^+0jtl&6DjPcLF=NjjBx zra#G1=t}8;k=jEfXZdMqPr3F5@WdAM#45UD$QJST{3{#I7~O!HHUJ61v)j`Rv3567 zq(C{0;i0{Ai`b)1(c@h{q zxXACli`kZ2Xz5D;@Bt5}6E@X90XA+)OmbI9XeDhaHt#BTA$0NV-+gRa8fW8*V-|;%f{u#Xz zs4YK+$}b_1_E^Qhpw9=x{2WbafDDeOMZr4d{V4J!T@|dVk(Oujs7Z1`&T0DA$=^bc z2g`J_d7u}A_9khGZAC4mv}(wof#HO9D>U2X?Q=s>Scn9rx334k@^7PggAU;xAf1o2 z4UTfvQZISZ=S^mYgleg@t&7v?T|K?y)6aGlHA`(}RxC$;h=Y(8Cj=fW&g8;;7pVx| zkF@UoA*=g7zGJt@U(&Dl6t|#@Qbmdsdx~!xFU+xIKB-MRhl+&Dp!my(Z)gJx|F7bw zk9D}*=vAE5gz8@DhByFc?e8E_3hWuk5!QA42jI4x&J4{mVepVBb_MHpIQ#4ll?icp z|15Gcw257aXtK!U654)GaF?I-qT5yh{k9LgMsI&$z|$SB+jb~E;1 z$k6ZZ^T6S8$Do&ZM1q4(C_p;3^bTQD((u)?ompCIl8bSL-LdLmXMc_s?5))PE$Hgu zAQ0%IMf>b@>)z8dq(p829SF7ru;ptiYdTe@`g#NSMLeDR_?YH?Yy~p~y)kN`|MGa2 z@?&6@^AY~Ly=)E+B%#>p_oZ^v6}tC{ny}z`bFj}tJ_CL|4iwXXRJ6nGMWlM)4Ayi7Tlz-?|AgRE>V0yi z@?QYGi>Kdva-S~k5b`-~e#)+$0@eyJMgA_b@u|(M&5xbHnszAj|6uC9A_V5MbbSAO zR!xl~7NxWSuA{Xhi@E!G+CQ>6C%JG@`7|hlx4)v-N9@Y4;bkrV%;?W`FCb3}wEOA% zqm9bn0cQ&zQ5|S!OQg_empKv@7@HNkqAU~NmVa3zl;z#>wryrlF=Ucgt} zrlk#6+DSO*zf4wKi^Y$BY* zNu(pSIhE(!dbjxkL0OJ#PLqWFTywhgYe~1CG-pWXBm?}lxYM>*vNMY;NB=!l6J;|p zk|nX7ykNY*N1@{)8O%YPLZ10!<8&p8(;}IE7pl@nRgpmH=@J5P2$Mfi|Y2ECJ$>)+ywxh>zkB_5_F=!6_7| zhzEt34rcgfGANBRfd?3MF|jl3{Ig`MZ6h@SegKll;jl3RK$v;n2zNM5GMdpt|C5< zL##-!9}dweOi>YDF{a}_Y0Vw?<8?qxRS`eNA?{CL)pDa#u&anCj4>U}2@qES;ZP9; z$uY#11PD*EQ>ai8FUBES6CiE@qEbaHNQvp#nqaLCr8otriufoFu{{AI$K({MRK$a( zm=0e8#CbqWQxRXqA!vem)TTOx=_(?e8q={W!4AF$h-wv)VvZrY6X@7zb_z38#K}0s zBMIhl0}wM+L{(Z$$G!xJJ!wv1mWp^Y4)It5tCG^4!fX|>B0XNmoqhKNAm*ru&*Kn} zC$P$q;S}bo2yaGA$B_h9y$*;P74c&nf+f(g+~O4GsfZ^mF&!+y4qgRBt%@kH#t_F7 z=WmW+(*0y0E@S_Z%nX_UNDEPHK+whfP{8Y`}IO z-y?(#Jz92pk(IIBc}DBR;M+6V&_h=CU%4idgG1XSTQ~Iih*aFeNhLYJQ%7i$c&gGq zG2875Fg*s8N(YQm+4ejfIg8*BQn@rGmzw}cQ~yq%J=)iM2OJpWz|p!$kcTQN1Kv}W z(JMThR0(f!7)B(glmWC#q0^*%;HOK~Kq2)4ovF0UQs``j&gn5g`&>!aP8g|{YG6Pu z4iAmIPTOj-MNiP<3x-_2Hn*&mJZ&La@PypnAp37yHA!Lx#nUs% z4w>wd4Q}7AmJUxa6ifn>Y&O493cKX^JJpF?*RAfq( zoe)1;HLXhc7ic*c**k5uw*DRL)FQ$4c4%aStD~c3Ylo|yeu%9f0SRh=*lae%TB;zB z{63O0>u-cm_QUKl(#?wJSjbK`cTTDB8FYRa*)r!CAtUVjxeeyOLc?vLx_W+f^=;Bf zZ?Gjb<>panqaVgrHATYT;Qim&$(r|4bikqBZ(e~b1lv1r86PIhRXdCQFfWY{ z^9p-qm5~o?fL+3ewQTkFLbiIYQ7`le!Q_yUt@LNI&o+oGIN!3chYR6SD^HJR;;i;) zFQ5u|!nzRdyYlqtBr$gVSj#z*yUW=kuQj*B-yXD8+3s?OY{*WNb-|G2?+nquum$y{ zy*NwyIg$||x5bdVD>OaO;qv&-6WQSPc-^5r0kd$pYVx#mZ{C}3xNS{8XfUA2waC>@FgI9 z+_xfUva*XSa@cQI!7^|;ntJxbX_Z!n;WM$u;B@p}9vF7a-W^*L5 z?|d2X*TBY}OllWA5LY0w=mvO658+J@ya9PB34Zub9MDL{0j-n_{RG=V3g1&C6Us}e zl6gRo(iGV!B1+Q-3?QE|kQC-a$!uYBKC?DjDkO_!ok%p2RY`)Sp$srod47@r&TPo2 z;V1k<%EUtQv}J)CkrDH5I_BzLOgB9f;u& z(8@sOJCF!k@kI9DU*7wzj!VlqmU=XG*jzkft{gH~4x6hkYKF|SMzd^##r?i7Dx9O` zQ*R2IY;nxMWoC`$9lUCqs zCHufN$ieI^6C6osPoQNM;~W)Xzo^XGG_)y4Esr0laU9U+_i6 zkfW`u)H!`AO(C@`#a&vd%t=349}X{TAcxWT*==&$gk{hZi1fKHk%=X5XsMC|b$c5yW{Gx!A$!E7CoFLykpTKI@J--uSwIk~&5W5iQ>TN{eLL`fTtfPyORs4*nUi$ZTZc;S( z1ZSXZ-clGsHcRdfcgWqM9(Ss4qC*=C5q7#B70v|`)WQf5C&mi`D5f}yGRS#vHb~F$)tz?h@|7zVTvT2MjrF*&7to5W;fZ*b!j$oyNM%{eb-r&aW4!Yi|X#StfVJcmd2LeG*ICUC^ueq*5*7!Gxk) zE4d)LqASo`#B16-l46`3SM&s6X0oAmd~UK3p#G3wPLC}H3bgn-y>ikve=yX7uTd9A z>(Ir=A8WMsZAVu}@+Wll7g*Qs{k>y4E+w^Z>8Rd#plfeefAaCxVSVw4zI;euKIr>I zKWEhLJhyqsKL1j~kp1ov`|2V4>S6nRUvomzIx>`8c5vnKrqS6o&^vp1|H=`IeaK=z zmoaRqI_LV>QvGh{!KK%V%LccMl-CZG*Ip_eE^oNBbEtgLaPi_R$%S%iR@1foqCvyC zv@^zEr=Cf@C|+7LY-1#!Pyl@|rsaKpuGX=;r-QFSOAx0AiqU8>s&Yk8eSZggAu-q>LGcN@6va%@pt&IJgEI5}Y`VNUTorL1lRA=-vS@i&ZFkT_)=L@9Z;K^J(9SCaLL z0*@3FSU8-g+XWrpkjSa~;RhIuP%@IL#puS|R&heaYrEPG!e)&=-(M{*d+V@O_QpYLm0+=lFH zNDd--6co_?KxE@x)D_sagnHoeNcUp%J|O3{(c4s9J;>T0tq}U5uORZwqj^Ml3fNhZ z*Y>}{lQ7$HprSm0;2tDOHqiz;J31gs;yt5V=nQSGng?soE*+@Um7vh~?8bp9cIB+| z^ncJOyrnzLcii{stma@z@1(83g^*3u-3~W?xS4pou6DPwx^bCDr<`gU292_~6)cL1 z$r`wC$vTg>y#b$5tfkpf$J|diuDr$bUTk0JIqX-UF8PxCszl6(4XRuf5ZHf)^<&)nR^eHJ6h@E?5_3Ie!6TJQOiJ zxt_rHgDppvI+YI!=uNVIl~Q;HArbGxSt&}Xd8Q#mFq46_DP<7!QM_$ z>Yx-*2p$|^7k^%)`yF(i;@PJ^Kd#$=6M35jpPr(76Iy@CM@~PzgQTNLmnjmDe~Qk%_L8WU|6_! zC#%V<$|HdX9cLZSHRKmV7eqTV&L);wM(w$$C_J61D zVt1dO+S?6nvOwYJx@jNwKZ69nLPftO;Rhp%pML0VBzV!Jc)_B06QX#Tp?Hy?Sol+{ z>nRrRbTtwzqLu1X$;%Xz3B?Gm#9}4J(%yc2$GAr^ASg$Z;sK$G52LG6bRmkaLeVoQ z?iI!Tpt$lB*Ms8Bv4lV`z{+{jXS~~ndaqyV>~J^G>(C4Txu6|9aZJPfgN3YrFi&?s zA?C3_O*bT4cn=uqP8!2+hA8M@X~X~ zyXzl{(ksoqcg{V3_vbt3{Bheh7lQVS-@YNAaU=8(`jczU>|{lU$qW*ah$EVn`Gr2cqO*gqtIjozuo_H)GnQXf*LMrv=d#zmzXd+gu=|( z0))?SbH|Ph z_GZ>w(CGo4OoH8_k$*;G73=^ma%%!U`{K?O_3n;*hUplUmXec6&O9EVsOjIQ*DY|ezMC8ywMA8IZ6+%J;?ov<( zh3fAGdk?%lR)(|&mrH=w9u7+}U27ALtBO=7h;Z$7mROyjk}Pk7#o29L zJ#glf<`q#rPw>Hj-E_&CtY~^j2}>qNb(uCbiQYdTD03KVO z?X$ouaUwkryx}}hz>RD(@=r0b8N^onqD(nZt=R(@0GzhAbr_ueR~EkTh$(c>BT`~8 z!=9}Y2!JgzIo%fExr9yO3?{*eY{CY9`*Jb{mxD{fZm>bJ7W3d3}?gJ93`r^zB-0 zFn=DTQm293YK9}HLu5wIAVj>IEI335WzF$`or2Bl>njEiXLr@W!@IJoHw=btD1Xz# zFKk%IP7U6$gMZn;iP6+;4o&@=L-Y;SAUz*2{fI!$GH{sbg|u)1jk3My0^Y*GVbqI~ zc&hX{>on`Zz4oH1zHj9i`Bn}i#}VMi|Fj-RJJ({1rjJD=;i#(VhgD@z9%?uNFzm>p zP>>ZXvPMI(7@)GvMMKra7*TaKtVY^|cV2oI1p6s+>x8hX=phJn#QG1^2@MSmXA^6I zH#i!T!n!1a!dP5~pgJP82^Xtd_crR`SasVw7ptiU)O2+lP+Rv_C(hOh)h*49aq*e; zmS$K@SiU^LjbGC{F!LumFQ6o)1^tf`7=i>M({Go8UvniFJI zA&_|yq3AYkR@63G*uKdQNjfRCsQOxg5Rnv!>yj=?gQ0jtH|=mbk|G9NgvK|MRpYu@ zxcM|pJ{QbN~_ABwIi3bRMgeLQDOrwaV=f2iRF{PKThkr|5PCMBP zt7Y(M_uyEpL{Q&2ariIpvb3jY(zR+wd{MIg`t~J9ZH8f;rOOq<$H%T5n@glBS|-20 z%Al@|m@J&-)9%7#!=hVASJpkK+_zY{ zZ@&2!PF1$O|JLNuY3X5U?c~w)j`GPMHB!7><)8cEwHFo&D!<{8w=7+-BV$KJJ0GDP z&Tc%}o-V7H>{|Ae&sJUSS@Kk;i%VusPoJJ`OBL_FUwraL)h}y5tDSeJ_PhpvxA7lr zcWk$ZQ?H*)GN9a7yDZeqbzE~ycfsC@DrVcSbJwNW#FDT6H#PI2U+=!v{;QYseC>0g zPdcwTr@JzIRq!ojx|noj)Ld-9tA zbvE-IdIp(e7!`OXj{Rr#2N)zDA?z$oZ!h`Du?!2dr>hlNVzCK31yb?T)t#uU3iy1r zX~BQF3y8kjr>n02llVmIg&UnM4m#1D{qEpS=J!5#a1ZwdQy6@e`=ZEwq=38YbO%e> zyCsJgwY3quHhNN+yH3+gM5b0c8CX(0_;MSrD4HTwp-3+M#LI9H{7&N*& zVGX5KS@)&>yyN|Aj`wHHlNR9ap<;A0f#6L50>!JHOYW-W!m@?({i(v11->Qi_FQUx zzcu;BlDop9*QE;U7x;RhuYe#56p%_7OuIft12v(SN9ZjQTKy1O_*k*eDildI{iTt@ zT`#$jIGdVX7?-DbE7$fV3^;3i@nuzyY?P?@6T$%ZdL27~Ekr 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