From 00ab46fb316c93094d2d48fd60c73a700c81daf9 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Tue, 12 Aug 2025 21:43:26 -0400 Subject: [PATCH 01/24] begin supervisord-ifying --- images/chromium-headful/Dockerfile | 8 +- .../image-chromium/x11vnc_startup.sh | 47 -------- .../supervisor/services/dbus.conf | 9 ++ .../supervisor/services/mutter.conf | 8 ++ .../supervisor/services/ncat.conf | 10 ++ .../supervisor/services/neko.conf | 8 ++ .../supervisor/services/xorg.conf | 8 ++ images/chromium-headful/supervisord.conf | 18 +++ images/chromium-headful/wrapper.sh | 103 +++++++++++------- 9 files changed, 124 insertions(+), 95 deletions(-) delete mode 100755 images/chromium-headful/image-chromium/x11vnc_startup.sh create mode 100644 images/chromium-headful/supervisor/services/dbus.conf create mode 100644 images/chromium-headful/supervisor/services/mutter.conf create mode 100644 images/chromium-headful/supervisor/services/ncat.conf create mode 100644 images/chromium-headful/supervisor/services/neko.conf create mode 100644 images/chromium-headful/supervisor/services/xorg.conf create mode 100644 images/chromium-headful/supervisord.conf diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 4be552d4..9c2271be 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -50,7 +50,6 @@ RUN apt-get update && \ imagemagick \ sudo \ mutter \ - x11vnc \ # Python/pyenv reqs build-essential \ libssl-dev \ @@ -141,11 +140,6 @@ RUN set -eux; \ RUN add-apt-repository -y ppa:xtradeb/apps RUN apt update -y && apt install -y chromium ncat -# Install noVNC -RUN git clone --branch v1.5.0 https://github.com/novnc/noVNC.git /opt/noVNC && \ - git clone --branch v0.12.0 https://github.com/novnc/websockify /opt/noVNC/utils/websockify && \ - ln -s /opt/noVNC/vnc.html /opt/noVNC/index.html - # setup desktop env & app ENV DISPLAY_NUM=1 ENV HEIGHT=768 @@ -161,6 +155,8 @@ COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xor COPY image-chromium/ / COPY ./wrapper.sh /wrapper.sh +COPY supervisord.conf /etc/supervisor/supervisord.conf +COPY supervisor/services/ /etc/supervisor/conf.d/services/ # copy the kernel-images API binary built externally COPY bin/kernel-images-api /usr/local/bin/kernel-images-api diff --git a/images/chromium-headful/image-chromium/x11vnc_startup.sh b/images/chromium-headful/image-chromium/x11vnc_startup.sh deleted file mode 100755 index 6f16940f..00000000 --- a/images/chromium-headful/image-chromium/x11vnc_startup.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -echo "starting vnc" - -(x11vnc -display $DISPLAY \ - -forever \ - -threads \ - -shared \ - -wait 50 \ - -rfbport 5900 \ - -nopw \ - 2>/tmp/x11vnc_stderr.log) & - -x11vnc_pid=$! - -# Wait for x11vnc to start -timeout=10 -while [ $timeout -gt 0 ]; do - if netstat -tuln | grep -q ":5900 "; then - break - fi - sleep 1 - ((timeout--)) -done - -if [ $timeout -eq 0 ]; then - echo "x11vnc failed to start, stderr output:" >&2 - cat /tmp/x11vnc_stderr.log >&2 - exit 1 -fi - -: > /tmp/x11vnc_stderr.log - -# Monitor x11vnc process in the background -( - while true; do - if ! kill -0 $x11vnc_pid 2>/dev/null; then - echo "x11vnc process crashed, restarting..." >&2 - if [ -f /tmp/x11vnc_stderr.log ]; then - echo "x11vnc stderr output:" >&2 - cat /tmp/x11vnc_stderr.log >&2 - rm /tmp/x11vnc_stderr.log - fi - exec "$0" - fi - sleep 5 - done -) & diff --git a/images/chromium-headful/supervisor/services/dbus.conf b/images/chromium-headful/supervisor/services/dbus.conf new file mode 100644 index 00000000..7995fa0c --- /dev/null +++ b/images/chromium-headful/supervisor/services/dbus.conf @@ -0,0 +1,9 @@ +[program:dbus] +command=/bin/bash -lc 'mkdir -p /run/dbus && dbus-uuidgen --ensure && dbus-daemon --system --address=unix:path=/run/dbus/system_bus_socket --nopidfile --nosyslog --nofork' +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/dbus.out.log +stderr_logfile=/var/log/dbus.err.log + + diff --git a/images/chromium-headful/supervisor/services/mutter.conf b/images/chromium-headful/supervisor/services/mutter.conf new file mode 100644 index 00000000..2301adf4 --- /dev/null +++ b/images/chromium-headful/supervisor/services/mutter.conf @@ -0,0 +1,8 @@ +[program:mutter] +command=/bin/bash -lc 'XDG_SESSION_TYPE=x11 mutter --replace --sm-disable' +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/mutter.out.log +stderr_logfile=/var/log/mutter.err.log + diff --git a/images/chromium-headful/supervisor/services/ncat.conf b/images/chromium-headful/supervisor/services/ncat.conf new file mode 100644 index 00000000..0e307910 --- /dev/null +++ b/images/chromium-headful/supervisor/services/ncat.conf @@ -0,0 +1,10 @@ +[program:ncat] +; Use env var expansion by launching under bash -lc so $CHROME_PORT and $INTERNAL_PORT resolve +command=/bin/bash -lc 'ncat --sh-exec "ncat 0.0.0.0 ${INTERNAL_PORT:-9223}" -l "${CHROME_PORT:-9222}" --keep-open' +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/ncat.out.log +stderr_logfile=/var/log/ncat.err.log + + diff --git a/images/chromium-headful/supervisor/services/neko.conf b/images/chromium-headful/supervisor/services/neko.conf new file mode 100644 index 00000000..8b584a8b --- /dev/null +++ b/images/chromium-headful/supervisor/services/neko.conf @@ -0,0 +1,8 @@ +[program:neko] +command=/usr/bin/neko serve --server.static /var/www --server.bind 0.0.0.0:8080 +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/neko.out.log +stderr_logfile=/var/log/neko.err.log + diff --git a/images/chromium-headful/supervisor/services/xorg.conf b/images/chromium-headful/supervisor/services/xorg.conf new file mode 100644 index 00000000..07261fbe --- /dev/null +++ b/images/chromium-headful/supervisor/services/xorg.conf @@ -0,0 +1,8 @@ +[program:xorg] +command=/usr/bin/Xorg :1 -config /etc/neko/xorg.conf -noreset -nolisten tcp +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/xorg.out.log +stderr_logfile=/var/log/xorg.err.log + diff --git a/images/chromium-headful/supervisord.conf b/images/chromium-headful/supervisord.conf new file mode 100644 index 00000000..1cc6272f --- /dev/null +++ b/images/chromium-headful/supervisord.conf @@ -0,0 +1,18 @@ +[unix_http_server] +file=/var/run/supervisor.sock + +[supervisord] +logfile=/var/log/supervisord.log +pidfile=/var/run/supervisord.pid +childlogdir=/var/log + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +[include] +files = /etc/supervisor/conf.d/services/*.conf + + diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index 7da22c79..1e076ba5 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -35,13 +35,42 @@ fi export DISPLAY=:1 -/usr/bin/Xorg :1 -config /etc/neko/xorg.conf -noreset -nolisten tcp & +# Predefine ports and export so supervisord programs (e.g., ncat) can read them +export INTERNAL_PORT="${INTERNAL_PORT:-9223}" +export CHROME_PORT="${CHROME_PORT:-9222}" -./mutter_startup.sh - -if [[ "${ENABLE_WEBRTC:-}" != "true" ]]; then - ./x11vnc_startup.sh +# Start supervisord early so it can manage Xorg and Mutter +echo "Starting supervisord" +supervisord -c /etc/supervisor/supervisord.conf +echo "Waiting for supervisord socket..." +for i in {1..30}; do +if [ -S /var/run/supervisor.sock ]; then + break fi +sleep 0.2 +done + +echo "Starting Xorg via supervisord" +supervisorctl -c /etc/supervisor/supervisord.conf start xorg +echo "Waiting for Xorg to open display $DISPLAY..." +for i in {1..50}; do + if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +echo "Starting Mutter via supervisord" +supervisorctl -c /etc/supervisor/supervisord.conf start mutter +echo "Waiting for Mutter to be ready..." +timeout=30 +while [ $timeout -gt 0 ]; do + if xdotool search --class "mutter" >/dev/null 2>&1; then + break + fi + sleep 1 + ((timeout--)) +done # ----------------------------------------------------------------------------- # House-keeping for the unprivileged "kernel" user -------------------------------- @@ -68,26 +97,20 @@ for dir in "${dirs[@]}"; do done # Ensure correct ownership (ignore errors if already correct) -chown -R kernel:kernel /home/kernel/user-data /home/kernel/.config /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true +chown -R kernel:kernel /home/kernel /home/kernel/user-data /home/kernel/.config /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true # ----------------------------------------------------------------------------- -# System-bus setup -------------------------------------------------------------- +# System-bus setup via supervisord -------------------------------------------- # ----------------------------------------------------------------------------- -# Start a lightweight system D-Bus daemon if one is not already running. We -# will later use this very same bus as the *session* bus as well, avoiding the -# autolaunch fallback that produced many "Connection refused" errors. -# Start a lightweight system D-Bus daemon if one is not already running (Chromium complains otherwise) -if [ ! -S /run/dbus/system_bus_socket ]; then - echo "Starting system D-Bus daemon" - mkdir -p /run/dbus - # Ensure a machine-id exists (required by dbus-daemon) - dbus-uuidgen --ensure - # Launch dbus-daemon in the background and remember its PID for cleanup - dbus-daemon --system \ - --address=unix:path=/run/dbus/system_bus_socket \ - --nopidfile --nosyslog --nofork >/dev/null 2>&1 & - dbus_pid=$! -fi +echo "Starting system D-Bus daemon via supervisord" +supervisorctl -c /etc/supervisor/supervisord.conf start dbus +echo "Waiting for D-Bus system bus socket..." +for i in {1..50}; do + if [ -S /run/dbus/system_bus_socket ]; then + break + fi + sleep 0.2 +done # We will point DBUS_SESSION_BUS_ADDRESS at the system bus socket to suppress # autolaunch attempts that failed and spammed logs. @@ -100,21 +123,17 @@ cleanup () { # Re-enable scale-to-zero if the script terminates early enable_scale_to_zero kill -TERM $pid - kill -TERM $pid2 # Kill the API server if it was started if [[ -n "${pid3:-}" ]]; then kill -TERM $pid3 || true fi - if [ -n "${dbus_pid:-}" ]; then - kill -TERM $dbus_pid 2>/dev/null || true - fi + # Stop supervised services + supervisorctl -c /etc/supervisor/supervisord.conf stop ncat || true + supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true } trap cleanup TERM INT pid= -pid2= pid3= -INTERNAL_PORT=9223 -CHROME_PORT=9222 # External port mapped to host echo "Starting Chromium on internal port $INTERNAL_PORT" # Load additional Chromium flags from /chromium/flags if present @@ -141,16 +160,20 @@ else ${CHROMIUM_FLAGS:-} >&2 & pid=$! fi -echo "Setting up ncat proxy on port $CHROME_PORT" -ncat \ - --sh-exec "ncat 0.0.0.0 $INTERNAL_PORT" \ - -l "$CHROME_PORT" \ - --keep-open & pid2=$! +echo "Starting ncat proxy via supervisord on port $CHROME_PORT" +supervisorctl -c /etc/supervisor/supervisord.conf start ncat +echo "Waiting for ncat to listen on 127.0.0.1:$CHROME_PORT..." +for i in {1..50}; do + if nc -z 127.0.0.1 "$CHROME_PORT" 2>/dev/null; then + break + fi + sleep 0.2 +done if [[ "${ENABLE_WEBRTC:-}" == "true" ]]; then # use webrtc - echo "✨ Starting neko (webrtc server)." - /usr/bin/neko serve --server.static /var/www --server.bind 0.0.0.0:8080 >&2 & + echo "✨ Starting neko (webrtc server) via supervisord." + supervisorctl -c /etc/supervisor/supervisord.conf start neko # Wait for neko to be ready. echo "Waiting for neko port 0.0.0.0:8080..." @@ -158,10 +181,6 @@ if [[ "${ENABLE_WEBRTC:-}" == "true" ]]; then sleep 0.5 done echo "Port 8080 is open" -else - # use novnc - ./novnc_startup.sh - echo "✨ noVNC demo is ready to use!" fi if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then @@ -239,5 +258,5 @@ if [[ -z "${WITHDOCKER:-}" ]]; then enable_scale_to_zero fi -# Keep the container running -tail -f /dev/null +# Keep the container running by tailing supervisord log +tail -F /var/log/supervisord.log From b760bc6c8c712c4b8821d92ee8602882115e98c3 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Tue, 12 Aug 2025 23:51:05 -0400 Subject: [PATCH 02/24] supervisord for all headful processes --- images/chromium-headful/Dockerfile | 2 + images/chromium-headful/start-chromium.sh | 42 +++++++ .../supervisor/services/chromium.conf | 9 ++ .../services/kernel-images-api.conf | 7 ++ images/chromium-headful/wrapper.sh | 104 ++++++++++-------- 5 files changed, 116 insertions(+), 48 deletions(-) create mode 100644 images/chromium-headful/start-chromium.sh create mode 100644 images/chromium-headful/supervisor/services/chromium.conf create mode 100644 images/chromium-headful/supervisor/services/kernel-images-api.conf diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 9c2271be..4aec10ac 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -154,6 +154,8 @@ COPY --from=xorg-deps /usr/local/lib/xorg/modules/drivers/dummy_drv.so /usr/lib/ COPY --from=xorg-deps /usr/local/lib/xorg/modules/input/neko_drv.so /usr/lib/xorg/modules/input/neko_drv.so COPY image-chromium/ / +COPY start-chromium.sh /images/chromium-headful/start-chromium.sh +RUN chmod +x /images/chromium-headful/start-chromium.sh COPY ./wrapper.sh /wrapper.sh COPY supervisord.conf /etc/supervisor/supervisord.conf COPY supervisor/services/ /etc/supervisor/conf.d/services/ diff --git a/images/chromium-headful/start-chromium.sh b/images/chromium-headful/start-chromium.sh new file mode 100644 index 00000000..8bfd528f --- /dev/null +++ b/images/chromium-headful/start-chromium.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +set -o pipefail -o errexit -o nounset + +# This script is launched by supervisord to start Chromium in the foreground. +# It mirrors the logic previously embedded in wrapper.sh. + +echo "Starting Chromium launcher" + +# Resolve internal port for the remote debugging interface +INTERNAL_PORT="${INTERNAL_PORT:-9223}" + +# Load additional Chromium flags from env and optional file +CHROMIUM_FLAGS="${CHROMIUM_FLAGS:-}" +if [[ -f /chromium/flags ]]; then + CHROMIUM_FLAGS="$CHROMIUM_FLAGS $(cat /chromium/flags)" +fi +echo "CHROMIUM_FLAGS: $CHROMIUM_FLAGS" + +# Always use display :1 and point DBus to the system bus socket +export DISPLAY=":1" +export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" + +RUN_AS_ROOT="${RUN_AS_ROOT:-false}" + +if [[ "$RUN_AS_ROOT" == "true" ]]; then + exec chromium \ + --remote-debugging-port="$INTERNAL_PORT" \ + ${CHROMIUM_FLAGS:-} +else + exec runuser -u kernel -- env \ + DISPLAY=":1" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" \ + XDG_CONFIG_HOME=/home/kernel/.config \ + XDG_CACHE_HOME=/home/kernel/.cache \ + HOME=/home/kernel \ + chromium \ + --remote-debugging-port="$INTERNAL_PORT" \ + ${CHROMIUM_FLAGS:-} +fi + + diff --git a/images/chromium-headful/supervisor/services/chromium.conf b/images/chromium-headful/supervisor/services/chromium.conf new file mode 100644 index 00000000..6609dd76 --- /dev/null +++ b/images/chromium-headful/supervisor/services/chromium.conf @@ -0,0 +1,9 @@ +[program:chromium] +command=/bin/bash -lc '/images/chromium-headful/start-chromium.sh' +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/chromium.out.log +stderr_logfile=/var/log/chromium.err.log + + diff --git a/images/chromium-headful/supervisor/services/kernel-images-api.conf b/images/chromium-headful/supervisor/services/kernel-images-api.conf new file mode 100644 index 00000000..d18aad82 --- /dev/null +++ b/images/chromium-headful/supervisor/services/kernel-images-api.conf @@ -0,0 +1,7 @@ +[program:kernel-images-api] +command=/bin/bash -lc 'mkdir -p "${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" && PORT="${KERNEL_IMAGES_API_PORT:-10001}" FRAME_RATE="${KERNEL_IMAGES_API_FRAME_RATE:-10}" DISPLAY_NUM="${KERNEL_IMAGES_API_DISPLAY_NUM:-${DISPLAY_NUM:-1}}" MAX_SIZE_MB="${KERNEL_IMAGES_API_MAX_SIZE_MB:-500}" OUTPUT_DIR="${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" exec /usr/local/bin/kernel-images-api' +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/kernel-images-api.out.log +stderr_logfile=/var/log/kernel-images-api.err.log diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index 1e076ba5..085619da 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -122,43 +122,28 @@ cleanup () { echo "Cleaning up..." # Re-enable scale-to-zero if the script terminates early enable_scale_to_zero - kill -TERM $pid - # Kill the API server if it was started - if [[ -n "${pid3:-}" ]]; then - kill -TERM $pid3 || true - fi - # Stop supervised services + supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true + supervisorctl -c /etc/supervisor/supervisord.conf stop kernel-images-api || true supervisorctl -c /etc/supervisor/supervisord.conf stop ncat || true supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true + # Stop log tailers + if [[ -n "${tail_pids[*]:-}" ]]; then + for tp in "${tail_pids[@]}"; do + kill -TERM "$tp" 2>/dev/null || true + done + fi } trap cleanup TERM INT -pid= -pid3= -echo "Starting Chromium on internal port $INTERNAL_PORT" - -# Load additional Chromium flags from /chromium/flags if present -CHROMIUM_FLAGS="${CHROMIUM_FLAGS:-}" -if [[ -f /chromium/flags ]]; then - CHROMIUM_FLAGS="$CHROMIUM_FLAGS $(cat /chromium/flags)" -fi -echo "CHROMIUM_FLAGS: $CHROMIUM_FLAGS" - -RUN_AS_ROOT=${RUN_AS_ROOT:-false} -if [[ "$RUN_AS_ROOT" == "true" ]]; then - DISPLAY=:1 DBUS_SESSION_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS" chromium \ - --remote-debugging-port=$INTERNAL_PORT \ - ${CHROMIUM_FLAGS:-} >&2 & pid=$! -else - runuser -u kernel -- env \ - DISPLAY=:1 \ - DBUS_SESSION_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS" \ - XDG_CONFIG_HOME=/home/kernel/.config \ - XDG_CACHE_HOME=/home/kernel/.cache \ - HOME=/home/kernel \ - chromium \ - --remote-debugging-port=$INTERNAL_PORT \ - ${CHROMIUM_FLAGS:-} >&2 & pid=$! -fi +tail_pids=() +echo "Starting Chromium via supervisord on internal port $INTERNAL_PORT" +supervisorctl -c /etc/supervisor/supervisord.conf start chromium +echo "Waiting for Chromium remote debugging on 127.0.0.1:$INTERNAL_PORT..." +for i in {1..100}; do + if nc -z 127.0.0.1 "$INTERNAL_PORT" 2>/dev/null; then + break + fi + sleep 0.2 +done echo "Starting ncat proxy via supervisord on port $CHROME_PORT" supervisorctl -c /etc/supervisor/supervisord.conf start ncat @@ -192,14 +177,8 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then API_MAX_SIZE_MB="${KERNEL_IMAGES_API_MAX_SIZE_MB:-500}" API_OUTPUT_DIR="${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" - mkdir -p "$API_OUTPUT_DIR" - - PORT="$API_PORT" \ - FRAME_RATE="$API_FRAME_RATE" \ - DISPLAY_NUM="$API_DISPLAY_NUM" \ - MAX_SIZE_MB="$API_MAX_SIZE_MB" \ - OUTPUT_DIR="$API_OUTPUT_DIR" \ - /usr/local/bin/kernel-images-api & pid3=$! + # Start via supervisord (env overrides are read by the service's command) + supervisorctl -c /etc/supervisor/supervisord.conf start kernel-images-api # close the "--no-sandbox unsupported flag" warning when running as root # in the unikernel runtime we haven't been able to get chromium to launch as non-root without cryptic crashpad errors # and when running as root you must use the --no-sandbox flag, which generates a warning @@ -212,12 +191,12 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then OFFSET_X=0 fi - # Wait for kernel-images API port 10001 to be ready. - echo "Waiting for kernel-images API port 127.0.0.1:10001..." - while ! nc -z 127.0.0.1 10001 2>/dev/null; do + # Wait for kernel-images API port to be ready. + echo "Waiting for kernel-images API port 127.0.0.1:${API_PORT}..." + while ! nc -z 127.0.0.1 "${API_PORT}" 2>/dev/null; do sleep 0.5 done - echo "Port 10001 is open" + echo "Port ${API_PORT} is open" # Wait for Chromium window to open before dismissing the --no-sandbox warning. target='New Tab - Chromium' @@ -241,7 +220,7 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then # Attempt to click the warning's close button echo "Clicking the warning's close button at x=$OFFSET_X y=115" if curl -s -o /dev/null -X POST \ - http://localhost:10001/computer/click_mouse \ + http://localhost:${API_PORT}/computer/click_mouse \ -H "Content-Type: application/json" \ -d "{\"x\":${OFFSET_X},\"y\":115}"; then echo "Successfully clicked the warning's close button" @@ -258,5 +237,34 @@ if [[ -z "${WITHDOCKER:-}" ]]; then enable_scale_to_zero fi -# Keep the container running by tailing supervisord log -tail -F /var/log/supervisord.log +# Tail and prefix logs from supervisord and all supervised services +echo "Tailing logs from supervisord and services..." +log_files=( + "supervisord:/var/log/supervisord.log" + "xorg.out:/var/log/xorg.out.log" + "xorg.err:/var/log/xorg.err.log" + "mutter.out:/var/log/mutter.out.log" + "mutter.err:/var/log/mutter.err.log" + "dbus.out:/var/log/dbus.out.log" + "dbus.err:/var/log/dbus.err.log" + "ncat.out:/var/log/ncat.out.log" + "ncat.err:/var/log/ncat.err.log" + "chromium.out:/var/log/chromium.out.log" + "chromium.err:/var/log/chromium.err.log" + "kernel-images-api.out:/var/log/kernel-images-api.out.log" + "kernel-images-api.err:/var/log/kernel-images-api.err.log" + "neko.out:/var/log/neko.out.log" + "neko.err:/var/log/neko.err.log" +) + +for entry in "${log_files[@]}"; do + label="${entry%%:*}" + file="${entry#*:}" + mkdir -p "$(dirname "$file")" + : > "$file" || true + tail -F "$file" | sed -u "s/^/[${label}] /" & + tail_pids+=("$!") +done + +# Keep the container running while streaming logs +wait From 848383369ef3675d3892bb350bedd922746d6401 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 00:03:56 -0400 Subject: [PATCH 03/24] multi tail --- images/chromium-headful/wrapper.sh | 99 ++++++++++++++++-------------- 1 file changed, 53 insertions(+), 46 deletions(-) diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index 085619da..89614033 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -39,6 +39,27 @@ export DISPLAY=:1 export INTERNAL_PORT="${INTERNAL_PORT:-9223}" export CHROME_PORT="${CHROME_PORT:-9222}" +# Track background tailing processes for cleanup +tail_pids=() + +# Cleanup handler (set early so we catch early failures) +cleanup () { + echo "Cleaning up..." + # Re-enable scale-to-zero if the script terminates early + enable_scale_to_zero + supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true + supervisorctl -c /etc/supervisor/supervisord.conf stop kernel-images-api || true + supervisorctl -c /etc/supervisor/supervisord.conf stop ncat || true + supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true + # Stop log tailers + if [[ -n "${tail_pids[*]:-}" ]]; then + for tp in "${tail_pids[@]}"; do + kill -TERM "$tp" 2>/dev/null || true + done + fi +} +trap cleanup TERM INT + # Start supervisord early so it can manage Xorg and Mutter echo "Starting supervisord" supervisord -c /etc/supervisor/supervisord.conf @@ -99,6 +120,38 @@ done # Ensure correct ownership (ignore errors if already correct) chown -R kernel:kernel /home/kernel /home/kernel/user-data /home/kernel/.config /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true +# ----------------------------------------------------------------------------- +# Dynamic log aggregation for /var/log ---------------------------------------- +# ----------------------------------------------------------------------------- +# Tails any existing and future *.log files under /var/log (recursively), +# prefixing each line with the relative filepath, e.g. [supervisord.log] ... +start_dynamic_log_aggregator() { + echo "Starting dynamic log aggregator for /var/log" + ( + declare -A tailed_files=() + start_tail() { + local f="$1" + [[ -f "$f" ]] || return 0 + [[ -n "${tailed_files[$f]:-}" ]] && return 0 + local label="${f#/var/log/}" + # Tie tails to this subshell lifetime so they exit when we stop it + tail --pid="$$" -n +1 -F "$f" 2>/dev/null | sed -u "s/^/[${label}] /" & + tailed_files[$f]=1 + } + # Periodically scan for new *.log files without extra dependencies + while true; do + while IFS= read -r -d '' f; do + start_tail "$f" + done < <(find /var/log -type f -name "*.log" -print0 2>/dev/null || true) + sleep 1 + done + ) & + tail_pids+=("$!") +} + +# Start log aggregator early so we see supervisor and service logs as they appear +start_dynamic_log_aggregator + # ----------------------------------------------------------------------------- # System-bus setup via supervisord -------------------------------------------- # ----------------------------------------------------------------------------- @@ -118,23 +171,6 @@ export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" # Start Chromium with display :1 and remote debugging, loading our recorder extension. # Use ncat to listen on 0.0.0.0:9222 since chromium does not let you listen on 0.0.0.0 anymore: https://github.com/pyppeteer/pyppeteer/pull/379#issuecomment-217029626 -cleanup () { - echo "Cleaning up..." - # Re-enable scale-to-zero if the script terminates early - enable_scale_to_zero - supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true - supervisorctl -c /etc/supervisor/supervisord.conf stop kernel-images-api || true - supervisorctl -c /etc/supervisor/supervisord.conf stop ncat || true - supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true - # Stop log tailers - if [[ -n "${tail_pids[*]:-}" ]]; then - for tp in "${tail_pids[@]}"; do - kill -TERM "$tp" 2>/dev/null || true - done - fi -} -trap cleanup TERM INT -tail_pids=() echo "Starting Chromium via supervisord on internal port $INTERNAL_PORT" supervisorctl -c /etc/supervisor/supervisord.conf start chromium echo "Waiting for Chromium remote debugging on 127.0.0.1:$INTERNAL_PORT..." @@ -237,34 +273,5 @@ if [[ -z "${WITHDOCKER:-}" ]]; then enable_scale_to_zero fi -# Tail and prefix logs from supervisord and all supervised services -echo "Tailing logs from supervisord and services..." -log_files=( - "supervisord:/var/log/supervisord.log" - "xorg.out:/var/log/xorg.out.log" - "xorg.err:/var/log/xorg.err.log" - "mutter.out:/var/log/mutter.out.log" - "mutter.err:/var/log/mutter.err.log" - "dbus.out:/var/log/dbus.out.log" - "dbus.err:/var/log/dbus.err.log" - "ncat.out:/var/log/ncat.out.log" - "ncat.err:/var/log/ncat.err.log" - "chromium.out:/var/log/chromium.out.log" - "chromium.err:/var/log/chromium.err.log" - "kernel-images-api.out:/var/log/kernel-images-api.out.log" - "kernel-images-api.err:/var/log/kernel-images-api.err.log" - "neko.out:/var/log/neko.out.log" - "neko.err:/var/log/neko.err.log" -) - -for entry in "${log_files[@]}"; do - label="${entry%%:*}" - file="${entry#*:}" - mkdir -p "$(dirname "$file")" - : > "$file" || true - tail -F "$file" | sed -u "s/^/[${label}] /" & - tail_pids+=("$!") -done - # Keep the container running while streaming logs wait From 5317aba8ed9dbe12fc1d5e926a72cf4a81f9ed9a Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 00:10:16 -0400 Subject: [PATCH 04/24] better logging --- .../supervisor/services/chromium.conf | 4 ++-- .../chromium-headful/supervisor/services/dbus.conf | 4 ++-- .../supervisor/services/kernel-images-api.conf | 4 ++-- .../supervisor/services/mutter.conf | 4 ++-- .../chromium-headful/supervisor/services/ncat.conf | 4 ++-- .../chromium-headful/supervisor/services/neko.conf | 4 ++-- .../chromium-headful/supervisor/services/xorg.conf | 4 ++-- images/chromium-headful/wrapper.sh | 13 +++++++------ 8 files changed, 21 insertions(+), 20 deletions(-) diff --git a/images/chromium-headful/supervisor/services/chromium.conf b/images/chromium-headful/supervisor/services/chromium.conf index 6609dd76..ed48e450 100644 --- a/images/chromium-headful/supervisor/services/chromium.conf +++ b/images/chromium-headful/supervisor/services/chromium.conf @@ -3,7 +3,7 @@ command=/bin/bash -lc '/images/chromium-headful/start-chromium.sh' autostart=false autorestart=true startsecs=2 -stdout_logfile=/var/log/chromium.out.log -stderr_logfile=/var/log/chromium.err.log +stdout_logfile=/var/log/supervisord/chromium +redirect_stderr=true diff --git a/images/chromium-headful/supervisor/services/dbus.conf b/images/chromium-headful/supervisor/services/dbus.conf index 7995fa0c..304a4c46 100644 --- a/images/chromium-headful/supervisor/services/dbus.conf +++ b/images/chromium-headful/supervisor/services/dbus.conf @@ -3,7 +3,7 @@ command=/bin/bash -lc 'mkdir -p /run/dbus && dbus-uuidgen --ensure && dbus-daemo autostart=false autorestart=true startsecs=2 -stdout_logfile=/var/log/dbus.out.log -stderr_logfile=/var/log/dbus.err.log +stdout_logfile=/var/log/supervisord/dbus +redirect_stderr=true diff --git a/images/chromium-headful/supervisor/services/kernel-images-api.conf b/images/chromium-headful/supervisor/services/kernel-images-api.conf index d18aad82..a04bfb35 100644 --- a/images/chromium-headful/supervisor/services/kernel-images-api.conf +++ b/images/chromium-headful/supervisor/services/kernel-images-api.conf @@ -3,5 +3,5 @@ command=/bin/bash -lc 'mkdir -p "${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" & autostart=false autorestart=true startsecs=2 -stdout_logfile=/var/log/kernel-images-api.out.log -stderr_logfile=/var/log/kernel-images-api.err.log +stdout_logfile=/var/log/supervisord/kernel-images-api +redirect_stderr=true diff --git a/images/chromium-headful/supervisor/services/mutter.conf b/images/chromium-headful/supervisor/services/mutter.conf index 2301adf4..b18c71c2 100644 --- a/images/chromium-headful/supervisor/services/mutter.conf +++ b/images/chromium-headful/supervisor/services/mutter.conf @@ -3,6 +3,6 @@ command=/bin/bash -lc 'XDG_SESSION_TYPE=x11 mutter --replace --sm-disable' autostart=false autorestart=true startsecs=2 -stdout_logfile=/var/log/mutter.out.log -stderr_logfile=/var/log/mutter.err.log +stdout_logfile=/var/log/supervisord/mutter +redirect_stderr=true diff --git a/images/chromium-headful/supervisor/services/ncat.conf b/images/chromium-headful/supervisor/services/ncat.conf index 0e307910..7d2b7855 100644 --- a/images/chromium-headful/supervisor/services/ncat.conf +++ b/images/chromium-headful/supervisor/services/ncat.conf @@ -4,7 +4,7 @@ command=/bin/bash -lc 'ncat --sh-exec "ncat 0.0.0.0 ${INTERNAL_PORT:-9223}" -l " autostart=false autorestart=true startsecs=2 -stdout_logfile=/var/log/ncat.out.log -stderr_logfile=/var/log/ncat.err.log +stdout_logfile=/var/log/supervisord/ncat +redirect_stderr=true diff --git a/images/chromium-headful/supervisor/services/neko.conf b/images/chromium-headful/supervisor/services/neko.conf index 8b584a8b..89018114 100644 --- a/images/chromium-headful/supervisor/services/neko.conf +++ b/images/chromium-headful/supervisor/services/neko.conf @@ -3,6 +3,6 @@ command=/usr/bin/neko serve --server.static /var/www --server.bind 0.0.0.0:8080 autostart=false autorestart=true startsecs=2 -stdout_logfile=/var/log/neko.out.log -stderr_logfile=/var/log/neko.err.log +stdout_logfile=/var/log/supervisord/neko +redirect_stderr=true diff --git a/images/chromium-headful/supervisor/services/xorg.conf b/images/chromium-headful/supervisor/services/xorg.conf index 07261fbe..659ac45b 100644 --- a/images/chromium-headful/supervisor/services/xorg.conf +++ b/images/chromium-headful/supervisor/services/xorg.conf @@ -3,6 +3,6 @@ command=/usr/bin/Xorg :1 -config /etc/neko/xorg.conf -noreset -nolisten tcp autostart=false autorestart=true startsecs=2 -stdout_logfile=/var/log/xorg.out.log -stderr_logfile=/var/log/xorg.err.log +stdout_logfile=/var/log/supervisord/xorg +redirect_stderr=true diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index 89614033..abbc25f3 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -109,6 +109,7 @@ dirs=( /home/kernel/.cache/dconf /tmp /var/log + /var/log/supervisord ) for dir in "${dirs[@]}"; do @@ -121,19 +122,19 @@ done chown -R kernel:kernel /home/kernel /home/kernel/user-data /home/kernel/.config /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true # ----------------------------------------------------------------------------- -# Dynamic log aggregation for /var/log ---------------------------------------- +# Dynamic log aggregation for /var/log/supervisord ----------------------------- # ----------------------------------------------------------------------------- -# Tails any existing and future *.log files under /var/log (recursively), -# prefixing each line with the relative filepath, e.g. [supervisord.log] ... +# Tails any existing and future files under /var/log/supervisord, +# prefixing each line with the relative filepath, e.g. [chromium] ... start_dynamic_log_aggregator() { - echo "Starting dynamic log aggregator for /var/log" + echo "Starting dynamic log aggregator for /var/log/supervisord" ( declare -A tailed_files=() start_tail() { local f="$1" [[ -f "$f" ]] || return 0 [[ -n "${tailed_files[$f]:-}" ]] && return 0 - local label="${f#/var/log/}" + local label="${f#/var/log/supervisord/}" # Tie tails to this subshell lifetime so they exit when we stop it tail --pid="$$" -n +1 -F "$f" 2>/dev/null | sed -u "s/^/[${label}] /" & tailed_files[$f]=1 @@ -142,7 +143,7 @@ start_dynamic_log_aggregator() { while true; do while IFS= read -r -d '' f; do start_tail "$f" - done < <(find /var/log -type f -name "*.log" -print0 2>/dev/null || true) + done < <(find /var/log/supervisord -type f -print0 2>/dev/null || true) sleep 1 done ) & From 112d4c94ab6eee3204d0df42d817066c1d4aee0e Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 00:12:36 -0400 Subject: [PATCH 05/24] reorder --- images/chromium-headful/wrapper.sh | 120 ++++++++++++++--------------- 1 file changed, 60 insertions(+), 60 deletions(-) diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index abbc25f3..3e331843 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -33,66 +33,6 @@ if [[ -z "${WITHDOCKER:-}" ]]; then disable_scale_to_zero fi -export DISPLAY=:1 - -# Predefine ports and export so supervisord programs (e.g., ncat) can read them -export INTERNAL_PORT="${INTERNAL_PORT:-9223}" -export CHROME_PORT="${CHROME_PORT:-9222}" - -# Track background tailing processes for cleanup -tail_pids=() - -# Cleanup handler (set early so we catch early failures) -cleanup () { - echo "Cleaning up..." - # Re-enable scale-to-zero if the script terminates early - enable_scale_to_zero - supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true - supervisorctl -c /etc/supervisor/supervisord.conf stop kernel-images-api || true - supervisorctl -c /etc/supervisor/supervisord.conf stop ncat || true - supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true - # Stop log tailers - if [[ -n "${tail_pids[*]:-}" ]]; then - for tp in "${tail_pids[@]}"; do - kill -TERM "$tp" 2>/dev/null || true - done - fi -} -trap cleanup TERM INT - -# Start supervisord early so it can manage Xorg and Mutter -echo "Starting supervisord" -supervisord -c /etc/supervisor/supervisord.conf -echo "Waiting for supervisord socket..." -for i in {1..30}; do -if [ -S /var/run/supervisor.sock ]; then - break -fi -sleep 0.2 -done - -echo "Starting Xorg via supervisord" -supervisorctl -c /etc/supervisor/supervisord.conf start xorg -echo "Waiting for Xorg to open display $DISPLAY..." -for i in {1..50}; do - if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then - break - fi - sleep 0.2 -done - -echo "Starting Mutter via supervisord" -supervisorctl -c /etc/supervisor/supervisord.conf start mutter -echo "Waiting for Mutter to be ready..." -timeout=30 -while [ $timeout -gt 0 ]; do - if xdotool search --class "mutter" >/dev/null 2>&1; then - break - fi - sleep 1 - ((timeout--)) -done - # ----------------------------------------------------------------------------- # House-keeping for the unprivileged "kernel" user -------------------------------- # Some Chromium subsystems want to create files under $HOME (NSS cert DB, dconf @@ -153,6 +93,66 @@ start_dynamic_log_aggregator() { # Start log aggregator early so we see supervisor and service logs as they appear start_dynamic_log_aggregator +export DISPLAY=:1 + +# Predefine ports and export so supervisord programs (e.g., ncat) can read them +export INTERNAL_PORT="${INTERNAL_PORT:-9223}" +export CHROME_PORT="${CHROME_PORT:-9222}" + +# Track background tailing processes for cleanup +tail_pids=() + +# Cleanup handler (set early so we catch early failures) +cleanup () { + echo "Cleaning up..." + # Re-enable scale-to-zero if the script terminates early + enable_scale_to_zero + supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true + supervisorctl -c /etc/supervisor/supervisord.conf stop kernel-images-api || true + supervisorctl -c /etc/supervisor/supervisord.conf stop ncat || true + supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true + # Stop log tailers + if [[ -n "${tail_pids[*]:-}" ]]; then + for tp in "${tail_pids[@]}"; do + kill -TERM "$tp" 2>/dev/null || true + done + fi +} +trap cleanup TERM INT + +# Start supervisord early so it can manage Xorg and Mutter +echo "Starting supervisord" +supervisord -c /etc/supervisor/supervisord.conf +echo "Waiting for supervisord socket..." +for i in {1..30}; do +if [ -S /var/run/supervisor.sock ]; then + break +fi +sleep 0.2 +done + +echo "Starting Xorg via supervisord" +supervisorctl -c /etc/supervisor/supervisord.conf start xorg +echo "Waiting for Xorg to open display $DISPLAY..." +for i in {1..50}; do + if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done + +echo "Starting Mutter via supervisord" +supervisorctl -c /etc/supervisor/supervisord.conf start mutter +echo "Waiting for Mutter to be ready..." +timeout=30 +while [ $timeout -gt 0 ]; do + if xdotool search --class "mutter" >/dev/null 2>&1; then + break + fi + sleep 1 + ((timeout--)) +done + # ----------------------------------------------------------------------------- # System-bus setup via supervisord -------------------------------------------- # ----------------------------------------------------------------------------- From 1268538011571a63dbd8a9f8b4d35bb5f065bf88 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 00:16:29 -0400 Subject: [PATCH 06/24] prefix wrapper logs --- images/chromium-headful/wrapper.sh | 58 +++++++++++++++--------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index 3e331843..b2f60193 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -21,7 +21,7 @@ scale_to_zero_write() { if [[ -e "$SCALE_TO_ZERO_FILE" ]]; then # Write the character, but do not fail the whole script if this errors out echo -n "$char" > "$SCALE_TO_ZERO_FILE" 2>/dev/null || \ - echo "Failed to write to scale-to-zero control file" >&2 + echo "[wrapper] Failed to write to scale-to-zero control file" >&2 fi } disable_scale_to_zero() { scale_to_zero_write "+"; } @@ -29,7 +29,7 @@ enable_scale_to_zero() { scale_to_zero_write "-"; } # Disable scale-to-zero for the duration of the script when not running under Docker if [[ -z "${WITHDOCKER:-}" ]]; then - echo "Disabling scale-to-zero" + echo "[wrapper] Disabling scale-to-zero" disable_scale_to_zero fi @@ -67,7 +67,7 @@ chown -R kernel:kernel /home/kernel /home/kernel/user-data /home/kernel/.config # Tails any existing and future files under /var/log/supervisord, # prefixing each line with the relative filepath, e.g. [chromium] ... start_dynamic_log_aggregator() { - echo "Starting dynamic log aggregator for /var/log/supervisord" + echo "[wrapper] Starting dynamic log aggregator for /var/log/supervisord" ( declare -A tailed_files=() start_tail() { @@ -104,7 +104,7 @@ tail_pids=() # Cleanup handler (set early so we catch early failures) cleanup () { - echo "Cleaning up..." + echo "[wrapper] Cleaning up..." # Re-enable scale-to-zero if the script terminates early enable_scale_to_zero supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true @@ -121,9 +121,9 @@ cleanup () { trap cleanup TERM INT # Start supervisord early so it can manage Xorg and Mutter -echo "Starting supervisord" +echo "[wrapper] Starting supervisord" supervisord -c /etc/supervisor/supervisord.conf -echo "Waiting for supervisord socket..." +echo "[wrapper] Waiting for supervisord socket..." for i in {1..30}; do if [ -S /var/run/supervisor.sock ]; then break @@ -131,9 +131,9 @@ fi sleep 0.2 done -echo "Starting Xorg via supervisord" +echo "[wrapper] Starting Xorg via supervisord" supervisorctl -c /etc/supervisor/supervisord.conf start xorg -echo "Waiting for Xorg to open display $DISPLAY..." +echo "[wrapper] Waiting for Xorg to open display $DISPLAY..." for i in {1..50}; do if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then break @@ -141,9 +141,9 @@ for i in {1..50}; do sleep 0.2 done -echo "Starting Mutter via supervisord" +echo "[wrapper] Starting Mutter via supervisord" supervisorctl -c /etc/supervisor/supervisord.conf start mutter -echo "Waiting for Mutter to be ready..." +echo "[wrapper] Waiting for Mutter to be ready..." timeout=30 while [ $timeout -gt 0 ]; do if xdotool search --class "mutter" >/dev/null 2>&1; then @@ -156,9 +156,9 @@ done # ----------------------------------------------------------------------------- # System-bus setup via supervisord -------------------------------------------- # ----------------------------------------------------------------------------- -echo "Starting system D-Bus daemon via supervisord" +echo "[wrapper] Starting system D-Bus daemon via supervisord" supervisorctl -c /etc/supervisor/supervisord.conf start dbus -echo "Waiting for D-Bus system bus socket..." +echo "[wrapper] Waiting for D-Bus system bus socket..." for i in {1..50}; do if [ -S /run/dbus/system_bus_socket ]; then break @@ -172,9 +172,9 @@ export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" # Start Chromium with display :1 and remote debugging, loading our recorder extension. # Use ncat to listen on 0.0.0.0:9222 since chromium does not let you listen on 0.0.0.0 anymore: https://github.com/pyppeteer/pyppeteer/pull/379#issuecomment-217029626 -echo "Starting Chromium via supervisord on internal port $INTERNAL_PORT" +echo "[wrapper] Starting Chromium via supervisord on internal port $INTERNAL_PORT" supervisorctl -c /etc/supervisor/supervisord.conf start chromium -echo "Waiting for Chromium remote debugging on 127.0.0.1:$INTERNAL_PORT..." +echo "[wrapper] Waiting for Chromium remote debugging on 127.0.0.1:$INTERNAL_PORT..." for i in {1..100}; do if nc -z 127.0.0.1 "$INTERNAL_PORT" 2>/dev/null; then break @@ -182,9 +182,9 @@ for i in {1..100}; do sleep 0.2 done -echo "Starting ncat proxy via supervisord on port $CHROME_PORT" +echo "[wrapper] Starting ncat proxy via supervisord on port $CHROME_PORT" supervisorctl -c /etc/supervisor/supervisord.conf start ncat -echo "Waiting for ncat to listen on 127.0.0.1:$CHROME_PORT..." +echo "[wrapper] Waiting for ncat to listen on 127.0.0.1:$CHROME_PORT..." for i in {1..50}; do if nc -z 127.0.0.1 "$CHROME_PORT" 2>/dev/null; then break @@ -194,19 +194,19 @@ done if [[ "${ENABLE_WEBRTC:-}" == "true" ]]; then # use webrtc - echo "✨ Starting neko (webrtc server) via supervisord." + echo "[wrapper] ✨ Starting neko (webrtc server) via supervisord." supervisorctl -c /etc/supervisor/supervisord.conf start neko # Wait for neko to be ready. - echo "Waiting for neko port 0.0.0.0:8080..." + echo "[wrapper] Waiting for neko port 0.0.0.0:8080..." while ! nc -z 127.0.0.1 8080 2>/dev/null; do sleep 0.5 done - echo "Port 8080 is open" + echo "[wrapper] Port 8080 is open" fi if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then - echo "✨ Starting kernel-images API." + echo "[wrapper] ✨ Starting kernel-images API." API_PORT="${KERNEL_IMAGES_API_PORT:-10001}" API_FRAME_RATE="${KERNEL_IMAGES_API_FRAME_RATE:-10}" @@ -220,7 +220,7 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then # in the unikernel runtime we haven't been able to get chromium to launch as non-root without cryptic crashpad errors # and when running as root you must use the --no-sandbox flag, which generates a warning if [[ "${RUN_AS_ROOT:-}" == "true" ]]; then - echo "Running as root, attempting to dismiss the --no-sandbox unsupported flag warning" + echo "[wrapper] Running as root, attempting to dismiss the --no-sandbox unsupported flag warning" if read -r WIDTH HEIGHT <<< "$(xdotool getdisplaygeometry 2>/dev/null)"; then # Work out an x-coordinate slightly inside the right-hand edge of the OFFSET_X=$(( WIDTH - 30 )) @@ -229,21 +229,21 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then fi # Wait for kernel-images API port to be ready. - echo "Waiting for kernel-images API port 127.0.0.1:${API_PORT}..." + echo "[wrapper] Waiting for kernel-images API port 127.0.0.1:${API_PORT}..." while ! nc -z 127.0.0.1 "${API_PORT}" 2>/dev/null; do sleep 0.5 done - echo "Port ${API_PORT} is open" + echo "[wrapper] Port ${API_PORT} is open" # Wait for Chromium window to open before dismissing the --no-sandbox warning. target='New Tab - Chromium' - echo "Waiting for Chromium window \"${target}\" to appear and become active..." + echo "[wrapper] Waiting for Chromium window \"${target}\" to appear and become active..." while :; do win_id=$(xwininfo -root -tree 2>/dev/null | awk -v t="$target" '$0 ~ t {print $1; exit}') if [[ -n $win_id ]]; then win_id=${win_id%:} if xdotool windowactivate --sync "$win_id"; then - echo "Focused window $win_id ($target) on $DISPLAY" + echo "[wrapper] Focused window $win_id ($target) on $DISPLAY" break fi fi @@ -255,17 +255,17 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then sleep 5 # Attempt to click the warning's close button - echo "Clicking the warning's close button at x=$OFFSET_X y=115" + echo "[wrapper] Clicking the warning's close button at x=$OFFSET_X y=115" if curl -s -o /dev/null -X POST \ http://localhost:${API_PORT}/computer/click_mouse \ -H "Content-Type: application/json" \ -d "{\"x\":${OFFSET_X},\"y\":115}"; then - echo "Successfully clicked the warning's close button" + echo "[wrapper] Successfully clicked the warning's close button" else - echo "Failed to click the warning's close button" >&2 + echo "[wrapper] Failed to click the warning's close button" >&2 fi else - echo "xdotool failed to obtain display geometry; skipping sandbox warning dismissal." >&2 + echo "[wrapper] xdotool failed to obtain display geometry; skipping sandbox warning dismissal." >&2 fi fi fi From 80c22b2d16824adf7c58c34c44ca4ce9aa4ea1c5 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 08:36:24 -0400 Subject: [PATCH 07/24] headless chromium supervisor setup --- images/chromium-headless/image/Dockerfile | 11 +- .../chromium-headless/image/start-chromium.sh | 43 +++++ images/chromium-headless/image/start-xvfb.sh | 14 ++ .../image/supervisor/services/chromium.conf | 9 + .../image/supervisor/services/dbus.conf | 9 + .../services/kernel-images-api.conf | 9 + .../image/supervisor/services/ncat.conf | 10 + .../image/supervisor/services/xvfb.conf | 9 + .../chromium-headless/image/supervisord.conf | 18 ++ images/chromium-headless/image/wrapper.sh | 171 ++++++++---------- .../chromium-headless/image/xvfb_startup.sh | 52 ------ 11 files changed, 209 insertions(+), 146 deletions(-) create mode 100644 images/chromium-headless/image/start-chromium.sh create mode 100644 images/chromium-headless/image/start-xvfb.sh create mode 100644 images/chromium-headless/image/supervisor/services/chromium.conf create mode 100644 images/chromium-headless/image/supervisor/services/dbus.conf create mode 100644 images/chromium-headless/image/supervisor/services/kernel-images-api.conf create mode 100644 images/chromium-headless/image/supervisor/services/ncat.conf create mode 100644 images/chromium-headless/image/supervisor/services/xvfb.conf create mode 100644 images/chromium-headless/image/supervisord.conf delete mode 100755 images/chromium-headless/image/xvfb_startup.sh diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index 86a41f0c..eaba38cb 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -29,7 +29,8 @@ RUN set -xe; \ dbus-x11 \ xvfb \ x11-utils \ - software-properties-common; + software-properties-common \ + supervisor; RUN add-apt-repository -y ppa:xtradeb/apps RUN apt update -y && apt install -y chromium ncat @@ -50,11 +51,19 @@ RUN apt-get -yqq purge upower || true && rm -rf /var/lib/apt/lists/* # Create a non-root user with a home directory RUN useradd -m -s /bin/bash kernel +# Xvfb helper and supervisor-managed start scripts COPY ./xvfb_startup.sh /usr/bin/xvfb_startup.sh +COPY ./start-chromium.sh /images/chromium-headless/image/start-chromium.sh +COPY ./start-xvfb.sh /images/chromium-headless/image/start-xvfb.sh +RUN chmod +x /images/chromium-headless/image/start-chromium.sh /images/chromium-headless/image/start-xvfb.sh # Wrapper script set environment COPY ./wrapper.sh /usr/bin/wrapper.sh +# Supervisord configuration +COPY ./supervisord.conf /etc/supervisor/supervisord.conf +COPY ./supervisor/services/ /etc/supervisor/conf.d/services/ + # Copy the kernel-images API binary built during the build process COPY bin/kernel-images-api /usr/local/bin/kernel-images-api ENV WITH_KERNEL_IMAGES_API=false diff --git a/images/chromium-headless/image/start-chromium.sh b/images/chromium-headless/image/start-chromium.sh new file mode 100644 index 00000000..2037c323 --- /dev/null +++ b/images/chromium-headless/image/start-chromium.sh @@ -0,0 +1,43 @@ +#!/bin/bash + +set -o pipefail -o errexit -o nounset + +echo "Starting Chromium launcher (headless)" + +# Resolve internal port for the remote debugging interface +INTERNAL_PORT="${INTERNAL_PORT:-9223}" + +# Load additional Chromium flags from env and optional file +CHROMIUM_FLAGS="${CHROMIUM_FLAGS:-}" +if [[ -f /chromium/flags ]]; then + CHROMIUM_FLAGS="$CHROMIUM_FLAGS $(cat /chromium/flags)" +fi +echo "CHROMIUM_FLAGS: $CHROMIUM_FLAGS" + +# Always use display :1 and point DBus to the system bus socket +export DISPLAY=":1" +export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" + +RUN_AS_ROOT="${RUN_AS_ROOT:-false}" + +if [[ "$RUN_AS_ROOT" == "true" ]]; then + exec chromium \ + --headless \ + --remote-debugging-port="$INTERNAL_PORT" \ + --remote-allow-origins=* \ + ${CHROMIUM_FLAGS:-} +else + exec runuser -u kernel -- env \ + DISPLAY=":1" \ + DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" \ + XDG_CONFIG_HOME=/home/kernel/.config \ + XDG_CACHE_HOME=/home/kernel/.cache \ + HOME=/home/kernel \ + chromium \ + --headless \ + --remote-debugging-port="$INTERNAL_PORT" \ + --remote-allow-origins=* \ + ${CHROMIUM_FLAGS:-} +fi + + diff --git a/images/chromium-headless/image/start-xvfb.sh b/images/chromium-headless/image/start-xvfb.sh new file mode 100644 index 00000000..d75b63c0 --- /dev/null +++ b/images/chromium-headless/image/start-xvfb.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -o pipefail -o errexit -o nounset + +DISPLAY="${DISPLAY:-:1}" +WIDTH="${WIDTH:-1024}" +HEIGHT="${HEIGHT:-768}" +DPI="${DPI:-96}" + +echo "Starting Xvfb on ${DISPLAY} with ${WIDTH}x${HEIGHT}x24, DPI ${DPI}" + +exec Xvfb "$DISPLAY" -ac -screen 0 "${WIDTH}x${HEIGHT}x24" -retro -dpi "$DPI" -nolisten tcp -nolisten unix + + diff --git a/images/chromium-headless/image/supervisor/services/chromium.conf b/images/chromium-headless/image/supervisor/services/chromium.conf new file mode 100644 index 00000000..0862294f --- /dev/null +++ b/images/chromium-headless/image/supervisor/services/chromium.conf @@ -0,0 +1,9 @@ +[program:chromium] +command=/bin/bash -lc '/images/chromium-headless/image/start-chromium.sh' +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/supervisord/chromium +redirect_stderr=true + + diff --git a/images/chromium-headless/image/supervisor/services/dbus.conf b/images/chromium-headless/image/supervisor/services/dbus.conf new file mode 100644 index 00000000..304a4c46 --- /dev/null +++ b/images/chromium-headless/image/supervisor/services/dbus.conf @@ -0,0 +1,9 @@ +[program:dbus] +command=/bin/bash -lc 'mkdir -p /run/dbus && dbus-uuidgen --ensure && dbus-daemon --system --address=unix:path=/run/dbus/system_bus_socket --nopidfile --nosyslog --nofork' +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/supervisord/dbus +redirect_stderr=true + + diff --git a/images/chromium-headless/image/supervisor/services/kernel-images-api.conf b/images/chromium-headless/image/supervisor/services/kernel-images-api.conf new file mode 100644 index 00000000..43ec28cd --- /dev/null +++ b/images/chromium-headless/image/supervisor/services/kernel-images-api.conf @@ -0,0 +1,9 @@ +[program:kernel-images-api] +command=/bin/bash -lc 'mkdir -p "${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" && PORT="${KERNEL_IMAGES_API_PORT:-10001}" FRAME_RATE="${KERNEL_IMAGES_API_FRAME_RATE:-10}" DISPLAY_NUM="${KERNEL_IMAGES_API_DISPLAY_NUM:-${DISPLAY_NUM:-1}}" MAX_SIZE_MB="${KERNEL_IMAGES_API_MAX_SIZE_MB:-500}" OUTPUT_DIR="${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" exec /usr/local/bin/kernel-images-api' +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/supervisord/kernel-images-api +redirect_stderr=true + + diff --git a/images/chromium-headless/image/supervisor/services/ncat.conf b/images/chromium-headless/image/supervisor/services/ncat.conf new file mode 100644 index 00000000..7d2b7855 --- /dev/null +++ b/images/chromium-headless/image/supervisor/services/ncat.conf @@ -0,0 +1,10 @@ +[program:ncat] +; Use env var expansion by launching under bash -lc so $CHROME_PORT and $INTERNAL_PORT resolve +command=/bin/bash -lc 'ncat --sh-exec "ncat 0.0.0.0 ${INTERNAL_PORT:-9223}" -l "${CHROME_PORT:-9222}" --keep-open' +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/supervisord/ncat +redirect_stderr=true + + diff --git a/images/chromium-headless/image/supervisor/services/xvfb.conf b/images/chromium-headless/image/supervisor/services/xvfb.conf new file mode 100644 index 00000000..e84b1c02 --- /dev/null +++ b/images/chromium-headless/image/supervisor/services/xvfb.conf @@ -0,0 +1,9 @@ +[program:xvfb] +command=/bin/bash -lc '/images/chromium-headless/image/start-xvfb.sh' +autostart=false +autorestart=true +startsecs=2 +stdout_logfile=/var/log/supervisord/xvfb +redirect_stderr=true + + diff --git a/images/chromium-headless/image/supervisord.conf b/images/chromium-headless/image/supervisord.conf new file mode 100644 index 00000000..1cc6272f --- /dev/null +++ b/images/chromium-headless/image/supervisord.conf @@ -0,0 +1,18 @@ +[unix_http_server] +file=/var/run/supervisor.sock + +[supervisord] +logfile=/var/log/supervisord.log +pidfile=/var/run/supervisord.pid +childlogdir=/var/log + +[rpcinterface:supervisor] +supervisor.rpcinterface_factory = supervisor.rpcinterface:make_main_rpcinterface + +[supervisorctl] +serverurl=unix:///var/run/supervisor.sock + +[include] +files = /etc/supervisor/conf.d/services/*.conf + + diff --git a/images/chromium-headless/image/wrapper.sh b/images/chromium-headless/image/wrapper.sh index 51baec59..369e5bb9 100755 --- a/images/chromium-headless/image/wrapper.sh +++ b/images/chromium-headless/image/wrapper.sh @@ -61,17 +61,18 @@ if [ -z "${CHROMIUM_FLAGS:-}" ]; then --use-gl=disabled \ --use-mock-keychain" fi +export CHROMIUM_FLAGS # ----------------------------------------------------------------------------- -# House-keeping for the unprivileged "kernel" user -------------------------------- -# Some Chromium subsystems want to create files under $HOME (NSS cert DB, dconf -# cache). If those directories are missing or owned by root Chromium emits -# noisy error messages such as: -# [ERROR:crypto/nss_util.cc:48] Failed to create /home/kernel/.pki/nssdb ... -# dconf-CRITICAL **: unable to create directory '/home/kernel/.cache/dconf' -# Pre-create them and hand ownership to the user so the messages disappear. - -for dir in /home/kernel/.pki/nssdb /home/kernel/.cache/dconf; do +# House-keeping for the unprivileged "kernel" user ---------------------------- +# ----------------------------------------------------------------------------- +dirs=( + /home/kernel/.pki/nssdb + /home/kernel/.cache/dconf + /var/log + /var/log/supervisord +) +for dir in "${dirs[@]}"; do if [ ! -d "$dir" ]; then mkdir -p "$dir" fi @@ -79,97 +80,81 @@ done # Ensure correct ownership (ignore errors if already correct) chown -R kernel:kernel /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true -# ----------------------------------------------------------------------------- -# System-bus setup -------------------------------------------------------------- -# ----------------------------------------------------------------------------- -# Start a lightweight system D-Bus daemon if one is not already running. We -# will later use this very same bus as the *session* bus as well, avoiding the -# autolaunch fallback that produced many "Connection refused" errors. -# Start a lightweight system D-Bus daemon if one is not already running (Chromium complains otherwise) -if [ ! -S /run/dbus/system_bus_socket ]; then - echo "Starting system D-Bus daemon" - mkdir -p /run/dbus - # Ensure a machine-id exists (required by dbus-daemon) - dbus-uuidgen --ensure - # Launch dbus-daemon in the background and remember its PID for cleanup - dbus-daemon --system \ - --address=unix:path=/run/dbus/system_bus_socket \ - --nopidfile --nosyslog --nofork >/dev/null 2>&1 & - dbus_pid=$! -fi - -# We will point DBUS_SESSION_BUS_ADDRESS at the system bus socket to suppress -# autolaunch attempts that failed and spammed logs. -export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" +# Export common env used by services +export DISPLAY=:1 +export HEIGHT=${HEIGHT:-768} +export WIDTH=${WIDTH:-1024} +export INTERNAL_PORT="${INTERNAL_PORT:-9223}" +export CHROME_PORT="${CHROME_PORT:-9222}" -# Start Chromium in headless mode with remote debugging -# Use ncat to listen on 0.0.0.0:9222 since chromium does not let you listen on 0.0.0.0 anymore: https://github.com/pyppeteer/pyppeteer/pull/379#issuecomment-217029626 +# Cleanup handler cleanup () { - echo "Cleaning up..." - kill -TERM $pid 2>/dev/null || true - kill -TERM $pid2 2>/dev/null || true - if [[ -n "${pid3:-}" ]]; then - kill -TERM $pid3 2>/dev/null || true - fi - if [ -n "${dbus_pid:-}" ]; then - kill -TERM $dbus_pid 2>/dev/null || true - fi + echo "[wrapper] Cleaning up..." + supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true + supervisorctl -c /etc/supervisor/supervisord.conf stop ncat || true + supervisorctl -c /etc/supervisor/supervisord.conf stop xvfb || true + supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true + supervisorctl -c /etc/supervisor/supervisord.conf stop kernel-images-api || true } trap cleanup TERM INT -pid= -pid2= -pid3= -INTERNAL_PORT=9223 -CHROME_PORT=9222 # External port mapped to host -echo "Starting Chromium on internal port $INTERNAL_PORT" -export CHROMIUM_FLAGS -# Launch Chromium as the non-root user "kernel" -export HEIGHT=768 -export WIDTH=1024 -export DISPLAY=:1 -echo "Starting Xvfb" -/usr/bin/xvfb_startup.sh -runuser -u kernel -- env DISPLAY=:1 DBUS_SESSION_BUS_ADDRESS="$DBUS_SESSION_BUS_ADDRESS" chromium \ - --headless \ - --remote-debugging-port=$INTERNAL_PORT \ - --remote-allow-origins=* \ - ${CHROMIUM_FLAGS:-} 2>&1 \ - | grep -vE "org\.freedesktop\.UPower|Failed to connect to the bus|google_apis" >&2 & -pid=$! -echo "Setting up ncat proxy on port $CHROME_PORT" -ncat \ - --sh-exec "ncat 0.0.0.0 $INTERNAL_PORT" \ - -l "$CHROME_PORT" \ - --keep-open & pid2=$! -# Optionally start the kernel-images API server file i/o -if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then - echo "✨ Starting kernel-images API." - API_PORT="${KERNEL_IMAGES_API_PORT:-10001}" - API_FRAME_RATE="${KERNEL_IMAGES_API_FRAME_RATE:-10}" - API_DISPLAY_NUM="${KERNEL_IMAGES_API_DISPLAY_NUM:-${DISPLAY_NUM:-1}}" - API_MAX_SIZE_MB="${KERNEL_IMAGES_API_MAX_SIZE_MB:-500}" - API_OUTPUT_DIR="${KERNEL_IMAGES_API_OUTPUT_DIR:-/recordings}" +echo "[wrapper] Starting supervisord" +supervisord -c /etc/supervisor/supervisord.conf +echo "[wrapper] Waiting for supervisord socket..." +for i in {1..30}; do + if [ -S /var/run/supervisor.sock ]; then + break + fi + sleep 0.2 +done - mkdir -p "$API_OUTPUT_DIR" +echo "[wrapper] Starting system D-Bus daemon via supervisord" +supervisorctl -c /etc/supervisor/supervisord.conf start dbus +for i in {1..50}; do + if [ -S /run/dbus/system_bus_socket ]; then + break + fi + sleep 0.2 +done +export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" - PORT="$API_PORT" \ - FRAME_RATE="$API_FRAME_RATE" \ - DISPLAY_NUM="$API_DISPLAY_NUM" \ - MAX_SIZE_MB="$API_MAX_SIZE_MB" \ - OUTPUT_DIR="$API_OUTPUT_DIR" \ - /usr/local/bin/kernel-images-api & pid3=$! -fi +echo "[wrapper] Starting Xvfb via supervisord" +supervisorctl -c /etc/supervisor/supervisord.conf start xvfb +for i in {1..50}; do + if xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then + break + fi + sleep 0.2 +done -# Wait for Chromium to exit; propagate its exit code -wait "$pid" -exit_code=$? -echo "Chromium exited with code $exit_code" -# Ensure ncat proxy is terminated -kill -TERM "$pid2" 2>/dev/null || true -# Ensure kernel-images API server is terminated -if [[ -n "${pid3:-}" ]]; then - kill -TERM "$pid3" 2>/dev/null || true +echo "[wrapper] Starting Chromium via supervisord on internal port $INTERNAL_PORT" +supervisorctl -c /etc/supervisor/supervisord.conf start chromium +for i in {1..100}; do + if ncat -z 127.0.0.1 "$INTERNAL_PORT" 2>/dev/null; then + break + fi + sleep 0.2 +done + +echo "[wrapper] Starting ncat proxy via supervisord on port $CHROME_PORT" +supervisorctl -c /etc/supervisor/supervisord.conf start ncat +for i in {1..50}; do + if ncat -z 127.0.0.1 "$CHROME_PORT" 2>/dev/null; then + break + fi + sleep 0.2 +done + +if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then + echo "[wrapper] ✨ Starting kernel-images API via supervisord." + supervisorctl -c /etc/supervisor/supervisord.conf start kernel-images-api + API_PORT="${KERNEL_IMAGES_API_PORT:-10001}" + echo "[wrapper] Waiting for kernel-images API on 127.0.0.1:${API_PORT}..." + while ! ncat -z 127.0.0.1 "${API_PORT}" 2>/dev/null; do + sleep 0.5 + done fi -exit "$exit_code" +# Keep running while services are supervised; stream supervisor logs +tail -n +1 -F /var/log/supervisord/* 2>/dev/null & +wait diff --git a/images/chromium-headless/image/xvfb_startup.sh b/images/chromium-headless/image/xvfb_startup.sh deleted file mode 100755 index dab83000..00000000 --- a/images/chromium-headless/image/xvfb_startup.sh +++ /dev/null @@ -1,52 +0,0 @@ -#!/bin/bash -set -e # Exit on error - -DPI=96 -RES_AND_DEPTH=${WIDTH}x${HEIGHT}x24 - -# Default DISPLAY to :1 if not provided and extract the numeric part -: ${DISPLAY:=':1'} -DISPLAY_NUM=${DISPLAY#:} - -# Function to check if Xvfb is already running -check_xvfb_running() { - if [ -e /tmp/.X${DISPLAY_NUM}-lock ]; then - return 0 # Xvfb is already running - else - return 1 # Xvfb is not running - fi -} - -# Function to check if Xvfb is ready -wait_for_xvfb() { - local timeout=10 - local start_time=$(date +%s) - while ! xdpyinfo >/dev/null 2>&1; do - if [ $(($(date +%s) - start_time)) -gt $timeout ]; then - echo "Xvfb failed to start within $timeout seconds" >&2 - return 1 - fi - sleep 0.1 - done - return 0 -} - -# Check if Xvfb is already running -if check_xvfb_running; then - echo "Xvfb is already running on display ${DISPLAY}" - exit 0 -fi - -# Start Xvfb -Xvfb $DISPLAY -ac -screen 0 $RES_AND_DEPTH -retro -dpi $DPI -nolisten tcp -nolisten unix & -XVFB_PID=$! - -# Wait for Xvfb to start -if wait_for_xvfb; then - echo "Xvfb started successfully on display ${DISPLAY}" - echo "Xvfb PID: $XVFB_PID" -else - echo "Xvfb failed to start" - kill $XVFB_PID - exit 1 -fi From 98c6b49e1b6de806f68b36af4d27c82ee116f658 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 16:07:31 +0000 Subject: [PATCH 08/24] /fs/upload /fs/upload_zip for writing many files --- server/cmd/api/api/fs.go | 273 ++++++++++++++ server/cmd/api/api/fs_test.go | 231 ++++++++++++ server/lib/oapi/oapi.go | 569 ++++++++++++++++++++++++++--- server/lib/ziputil/ziputil.go | 132 +++++++ server/lib/ziputil/ziputil_test.go | 115 ++++++ server/openapi.yaml | 66 ++++ 6 files changed, 1338 insertions(+), 48 deletions(-) create mode 100644 server/lib/ziputil/ziputil.go create mode 100644 server/lib/ziputil/ziputil_test.go diff --git a/server/cmd/api/api/fs.go b/server/cmd/api/api/fs.go index 3ba23fc1..3e580585 100644 --- a/server/cmd/api/api/fs.go +++ b/server/cmd/api/api/fs.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strconv" + "strings" "sync" @@ -18,6 +19,7 @@ import ( "github.com/nrednav/cuid2" "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/onkernel/kernel-images/server/lib/ziputil" ) // fsWatch represents an in-memory directory watch. @@ -536,3 +538,274 @@ func (s *ApiService) StreamFsEvents(ctx context.Context, req oapi.StreamFsEvents headers := oapi.StreamFsEvents200ResponseHeaders{XSSEContentType: "application/json"} return oapi.StreamFsEvents200TexteventStreamResponse{Body: pr, Headers: headers, ContentLength: 0}, nil } + +// UploadZip handles a multipart upload of a zip archive and extracts it to dest_path. +func (s *ApiService) UploadZip(ctx context.Context, request oapi.UploadZipRequestObject) (oapi.UploadZipResponseObject, error) { + log := logger.FromContext(ctx) + + if request.Body == nil { + return oapi.UploadZip400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body required"}}, nil + } + + // Create temp file for uploaded zip + tmpZip, err := os.CreateTemp("", "upload-*.zip") + if err != nil { + log.Error("failed to create temporary file", "err", err) + return oapi.UploadZip500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil + } + defer os.Remove(tmpZip.Name()) + defer tmpZip.Close() + + var destPath string + var fileReceived bool + + for { + part, err := request.Body.NextPart() + if err == io.EOF { + break + } + if err != nil { + log.Error("failed to read form part", "err", err) + return oapi.UploadZip400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read form part"}}, nil + } + + switch part.FormName() { + case "zip_file": + fileReceived = true + if _, err := io.Copy(tmpZip, part); err != nil { + log.Error("failed to read zip data", "err", err) + return oapi.UploadZip400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read zip file"}}, nil + } + case "dest_path": + data, err := io.ReadAll(part) + if err != nil { + log.Error("failed to read dest_path", "err", err) + return oapi.UploadZip400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read dest_path"}}, nil + } + destPath = strings.TrimSpace(string(data)) + if destPath == "" || !filepath.IsAbs(destPath) { + return oapi.UploadZip400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "dest_path must be an absolute path"}}, nil + } + default: + return oapi.UploadZip400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid form field: " + part.FormName()}}, nil + } + } + + // Validate required parts + if !fileReceived || destPath == "" { + return oapi.UploadZip400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "zip_file and dest_path are required"}}, nil + } + + // Ensure destination directory exists + if err := os.MkdirAll(destPath, 0o755); err != nil { + log.Error("failed to create destination directory", "err", err, "path", destPath) + return oapi.UploadZip500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create destination directory"}}, nil + } + + // Close temp writer prior to unzip + if err := tmpZip.Close(); err != nil { + log.Error("failed to finalize temporary zip", "err", err) + return oapi.UploadZip500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil + } + + if err := ziputil.Unzip(tmpZip.Name(), destPath); err != nil { + // Map common user errors to 400, otherwise 500 + msg := err.Error() + if strings.Contains(msg, "failed to open zip file") || strings.Contains(msg, "illegal file path") { + return oapi.UploadZip400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid zip file"}}, nil + } + log.Error("failed to extract zip", "err", err) + return oapi.UploadZip500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to extract zip"}}, nil + } + + return oapi.UploadZip201Response{}, nil +} + +// UploadFiles handles multipart form uploads for one or more files. It supports the following +// field name encodings: +// - files[].file and files[].dest_path +// - files[][file] and files[][dest_path] +// - files..file and files..dest_path +// +// Additionally, for single-file uploads it accepts: +// - file and dest_path +func (s *ApiService) UploadFiles(ctx context.Context, request oapi.UploadFilesRequestObject) (oapi.UploadFilesResponseObject, error) { + log := logger.FromContext(ctx) + + if request.Body == nil { + return oapi.UploadFiles400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body required"}}, nil + } + + // Track per-index pending uploads so order of parts does not matter. + type pendingUpload struct { + tempPath string + destPath string + fileReceived bool + } + + uploads := map[int]*pendingUpload{} + createdTemps := []string{} + defer func() { + for _, p := range createdTemps { + _ = os.Remove(p) + } + }() + + parseIndexAndField := func(name string) (int, string, bool) { + // single-file shorthand + if name == "file" || name == "dest_path" { + if name == "file" { + return 0, "file", true + } + return 0, "dest_path", true + } + if !strings.HasPrefix(name, "files") { + return 0, "", false + } + // forms like files[0].file or files[0][file] + if strings.HasPrefix(name, "files[") { + end := strings.Index(name, "]") + if end == -1 { + return 0, "", false + } + idxStr := name[len("files["):end] + rest := name[end+1:] + rest = strings.TrimPrefix(rest, ".") + var field string + if strings.HasPrefix(rest, "[") && strings.HasSuffix(rest, "]") { + field = rest[1 : len(rest)-1] + } else { + field = rest + } + idx := 0 + if v, err := strconv.Atoi(idxStr); err == nil && v >= 0 { + idx = v + } else { + return 0, "", false + } + return idx, field, true + } + // forms like files.0.file + if strings.HasPrefix(name, "files.") { + parts := strings.Split(name, ".") + if len(parts) != 3 { + return 0, "", false + } + idx := 0 + if v, err := strconv.Atoi(parts[1]); err == nil && v >= 0 { + idx = v + } else { + return 0, "", false + } + return idx, parts[2], true + } + return 0, "", false + } + + for { + part, err := request.Body.NextPart() + if err == io.EOF { + break + } + if err != nil { + log.Error("failed to read form part", "err", err) + return oapi.UploadFiles400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read form part"}}, nil + } + + name := part.FormName() + idx, field, ok := parseIndexAndField(name) + if !ok { + return oapi.UploadFiles400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid form field: " + name}}, nil + } + + pu, exists := uploads[idx] + if !exists { + pu = &pendingUpload{} + uploads[idx] = pu + } + + switch field { + case "file": + // Create temp for the file contents + tmp, err := os.CreateTemp("", "upload-*") + if err != nil { + log.Error("failed to create temporary file", "err", err) + return oapi.UploadFiles500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil + } + tmpPath := tmp.Name() + createdTemps = append(createdTemps, tmpPath) + if _, err := io.Copy(tmp, part); err != nil { + tmp.Close() + log.Error("failed to read upload data", "err", err) + return oapi.UploadFiles400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read upload data"}}, nil + } + if err := tmp.Close(); err != nil { + log.Error("failed to finalize temporary file", "err", err) + return oapi.UploadFiles500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil + } + pu.tempPath = tmpPath + pu.fileReceived = true + + case "dest_path": + data, err := io.ReadAll(part) + if err != nil { + log.Error("failed to read dest_path", "err", err) + return oapi.UploadFiles400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "failed to read dest_path"}}, nil + } + dest := strings.TrimSpace(string(data)) + if dest == "" || !filepath.IsAbs(dest) { + return oapi.UploadFiles400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "dest_path must be an absolute path"}}, nil + } + pu.destPath = dest + + default: + return oapi.UploadFiles400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid field: " + field}}, nil + } + } + + // Validate and materialize uploads + if len(uploads) == 0 { + return oapi.UploadFiles400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "no files provided"}}, nil + } + + for _, pu := range uploads { + if !pu.fileReceived || pu.destPath == "" { + return oapi.UploadFiles400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "each item must include file and dest_path"}}, nil + } + + // Ensure parent directory exists + if err := os.MkdirAll(filepath.Dir(pu.destPath), 0o755); err != nil { + log.Error("failed to create destination directories", "err", err, "path", pu.destPath) + return oapi.UploadFiles500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create destination directories"}}, nil + } + + // Copy temp -> destination + src, err := os.Open(pu.tempPath) + if err != nil { + log.Error("failed to open temporary file", "err", err) + return oapi.UploadFiles500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil + } + dst, err := os.OpenFile(pu.destPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) + if err != nil { + src.Close() + if errors.Is(err, os.ErrNotExist) { + return oapi.UploadFiles404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "destination not found"}}, nil + } + log.Error("failed to open destination file", "err", err, "path", pu.destPath) + return oapi.UploadFiles500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to open destination file"}}, nil + } + if _, err := io.Copy(dst, src); err != nil { + src.Close() + dst.Close() + log.Error("failed to write destination file", "err", err) + return oapi.UploadFiles500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to write destination file"}}, nil + } + _ = src.Close() + if err := dst.Close(); err != nil { + log.Error("failed to close destination file", "err", err) + return oapi.UploadFiles500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "internal error"}}, nil + } + } + + return oapi.UploadFiles201Response{}, nil +} diff --git a/server/cmd/api/api/fs_test.go b/server/cmd/api/api/fs_test.go index c4a8a70f..34daa1ec 100644 --- a/server/cmd/api/api/fs_test.go +++ b/server/cmd/api/api/fs_test.go @@ -1,15 +1,19 @@ package api import ( + "archive/zip" "bufio" + "bytes" "context" "io" + "mime/multipart" "os" "path/filepath" "strings" "testing" oapi "github.com/onkernel/kernel-images/server/lib/oapi" + "github.com/onkernel/kernel-images/server/lib/ziputil" ) // TestWriteReadFile verifies that files can be written and read back successfully. @@ -232,3 +236,230 @@ func TestFileDirOperations(t *testing.T) { t.Fatalf("unexpected DeleteDirectory resp: %T", resp) } } + +// helper to build multipart form for uploadFiles +func buildUploadMultipart(t *testing.T, parts map[string]string, files map[string]string) *multipart.Reader { + t.Helper() + pr, pw := io.Pipe() + mpw := multipart.NewWriter(pw) + + go func() { + // write fields + for name, val := range parts { + _ = mpw.WriteField(name, val) + } + // write files (string content) + for name, content := range files { + fw, _ := mpw.CreateFormField(name) + _, _ = io.Copy(fw, strings.NewReader(content)) + } + mpw.Close() + pw.Close() + }() + + return multipart.NewReader(pr, mpw.Boundary()) +} + +// helper to build multipart for UploadZip with binary zip bytes +func buildUploadZipMultipart(t *testing.T, destPath string, zipBytes []byte) *multipart.Reader { + t.Helper() + pr, pw := io.Pipe() + mpw := multipart.NewWriter(pw) + + go func() { + // dest_path field + if destPath != "" { + _ = mpw.WriteField("dest_path", destPath) + } + // binary zip part + if zipBytes != nil { + // Use form field named zip_file; file vs field does not matter for our handler + fw, _ := mpw.CreateFormFile("zip_file", "upload.zip") + _, _ = fw.Write(zipBytes) + } + mpw.Close() + pw.Close() + }() + + return multipart.NewReader(pr, mpw.Boundary()) +} + +func TestUploadFilesSingle(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{} + + tmp := t.TempDir() + dest := filepath.Join(tmp, "single.txt") + + // single-file shorthand: file + dest_path + reader := buildUploadMultipart(t, + map[string]string{"dest_path": dest}, + map[string]string{"file": "hello"}, + ) + + resp, err := svc.UploadFiles(ctx, oapi.UploadFilesRequestObject{Body: reader}) + if err != nil { + t.Fatalf("UploadFiles error: %v", err) + } + if _, ok := resp.(oapi.UploadFiles201Response); !ok { + t.Fatalf("unexpected response type: %T", resp) + } + data, err := os.ReadFile(dest) + if err != nil || string(data) != "hello" { + t.Fatalf("uploaded file mismatch: %v %q", err, string(data)) + } +} + +func TestUploadFilesMultipleAndOutOfOrder(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{} + + tmp := t.TempDir() + d1 := filepath.Join(tmp, "a.txt") + d2 := filepath.Join(tmp, "b.txt") + + // Use indexed fields with mixed ordering and bracket/dot styles + parts := map[string]string{ + "files[1][dest_path]": d2, + "files.0.dest_path": d1, + } + files := map[string]string{ + "files[1][file]": "two", + "files.0.file": "one", + } + reader := buildUploadMultipart(t, parts, files) + + resp, err := svc.UploadFiles(ctx, oapi.UploadFilesRequestObject{Body: reader}) + if err != nil { + t.Fatalf("UploadFiles error: %v", err) + } + if _, ok := resp.(oapi.UploadFiles201Response); !ok { + t.Fatalf("unexpected response type: %T", resp) + } + + if b, _ := os.ReadFile(d1); string(b) != "one" { + t.Fatalf("d1 mismatch: %q", string(b)) + } + if b, _ := os.ReadFile(d2); string(b) != "two" { + t.Fatalf("d2 mismatch: %q", string(b)) + } +} + +func TestUploadZipSuccess(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{} + + // Create a source directory with content + srcDir := t.TempDir() + nested := filepath.Join(srcDir, "dir", "sub") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + filePath := filepath.Join(nested, "a.txt") + if err := os.WriteFile(filePath, []byte("hello-zip"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + // Zip the directory + zipBytes, err := ziputil.ZipDir(srcDir) + if err != nil { + t.Fatalf("ZipDir error: %v", err) + } + + // Destination directory for extraction + destDir := t.TempDir() + + reader := buildUploadZipMultipart(t, destDir, zipBytes) + resp, err := svc.UploadZip(ctx, oapi.UploadZipRequestObject{Body: reader}) + if err != nil { + t.Fatalf("UploadZip error: %v", err) + } + if _, ok := resp.(oapi.UploadZip201Response); !ok { + t.Fatalf("unexpected UploadZip resp type: %T", resp) + } + + // Verify extracted content exists + extracted := filepath.Join(destDir, "dir", "sub", "a.txt") + data, err := os.ReadFile(extracted) + if err != nil { + t.Fatalf("read extracted: %v", err) + } + if string(data) != "hello-zip" { + t.Fatalf("extracted content mismatch: %q", string(data)) + } +} + +func TestUploadZipTraversalBlocked(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{} + + // Build a malicious zip with a path traversal entry + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + fh, _ := zw.Create("../evil.txt") + _, _ = fh.Write([]byte("pwned")) + _ = zw.Close() + + destDir := t.TempDir() + reader := buildUploadZipMultipart(t, destDir, buf.Bytes()) + resp, err := svc.UploadZip(ctx, oapi.UploadZipRequestObject{Body: reader}) + if err != nil { + t.Fatalf("UploadZip error: %v", err) + } + if _, ok := resp.(oapi.UploadZip400JSONResponse); !ok { + t.Fatalf("expected 400 for traversal, got %T", resp) + } + if _, err := os.Stat(filepath.Join(destDir, "evil.txt")); err == nil { + t.Fatalf("traversal file unexpectedly created") + } +} + +func TestUploadZipValidationErrors(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{} + + // Missing dest_path + reader1 := func() *multipart.Reader { + pr, pw := io.Pipe() + mpw := multipart.NewWriter(pw) + go func() { + fw, _ := mpw.CreateFormFile("zip_file", "z.zip") + _, _ = fw.Write([]byte("not-a-zip")) + mpw.Close() + pw.Close() + }() + return multipart.NewReader(pr, mpw.Boundary()) + }() + resp1, err := svc.UploadZip(ctx, oapi.UploadZipRequestObject{Body: reader1}) + if err != nil { + t.Fatalf("UploadZip error: %v", err) + } + if _, ok := resp1.(oapi.UploadZip400JSONResponse); !ok { + t.Fatalf("expected 400 for missing dest_path, got %T", resp1) + } + + // Missing zip_file + destDir := t.TempDir() + reader2 := func() *multipart.Reader { + pr, pw := io.Pipe() + mpw := multipart.NewWriter(pw) + go func() { + _ = mpw.WriteField("dest_path", destDir) + mpw.Close() + pw.Close() + }() + return multipart.NewReader(pr, mpw.Boundary()) + }() + resp2, err := svc.UploadZip(ctx, oapi.UploadZipRequestObject{Body: reader2}) + if err != nil { + t.Fatalf("UploadZip error: %v", err) + } + if _, ok := resp2.(oapi.UploadZip400JSONResponse); !ok { + t.Fatalf("expected 400 for missing zip_file, got %T", resp2) + } +} diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 14fa6ddf..4f0ab215 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -11,6 +11,7 @@ import ( "encoding/json" "fmt" "io" + "mime/multipart" "net/http" "net/url" "path" @@ -21,6 +22,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/oapi-codegen/runtime" strictnethttp "github.com/oapi-codegen/runtime/strictmiddleware/nethttp" + openapi_types "github.com/oapi-codegen/runtime/types" ) // Defines values for ClickMouseRequestButton. @@ -252,6 +254,22 @@ type ReadFileParams struct { Path string `form:"path" json:"path"` } +// UploadFilesMultipartBody defines parameters for UploadFiles. +type UploadFilesMultipartBody struct { + Files []struct { + // DestPath Absolute destination path to write the file. + DestPath string `json:"dest_path"` + File openapi_types.File `json:"file"` + } `json:"files"` +} + +// UploadZipMultipartBody defines parameters for UploadZip. +type UploadZipMultipartBody struct { + // DestPath Absolute destination directory to extract the archive to. + DestPath string `json:"dest_path"` + ZipFile openapi_types.File `json:"zip_file"` +} + // WriteFileParams defines parameters for WriteFile. type WriteFileParams struct { // Path Destination absolute file path. @@ -288,6 +306,12 @@ type MovePathJSONRequestBody = MovePathRequest // SetFilePermissionsJSONRequestBody defines body for SetFilePermissions for application/json ContentType. type SetFilePermissionsJSONRequestBody = SetFilePermissionsRequest +// UploadFilesMultipartRequestBody defines body for UploadFiles for multipart/form-data ContentType. +type UploadFilesMultipartRequestBody UploadFilesMultipartBody + +// UploadZipMultipartRequestBody defines body for UploadZip for multipart/form-data ContentType. +type UploadZipMultipartRequestBody UploadZipMultipartBody + // StartFsWatchJSONRequestBody defines body for StartFsWatch for application/json ContentType. type StartFsWatchJSONRequestBody = StartFsWatchRequest @@ -417,6 +441,12 @@ type ClientInterface interface { SetFilePermissions(ctx context.Context, body SetFilePermissionsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // UploadFilesWithBody request with any body + UploadFilesWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + // UploadZipWithBody request with any body + UploadZipWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // StartFsWatchWithBody request with any body StartFsWatchWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -657,6 +687,30 @@ func (c *Client) SetFilePermissions(ctx context.Context, body SetFilePermissions return c.Client.Do(req) } +func (c *Client) UploadFilesWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUploadFilesRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) UploadZipWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewUploadZipRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) StartFsWatchWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewStartFsWatchRequestWithBody(c.Server, contentType, body) if err != nil { @@ -1228,6 +1282,64 @@ func NewSetFilePermissionsRequestWithBody(server string, contentType string, bod return req, nil } +// NewUploadFilesRequestWithBody generates requests for UploadFiles with any type of body +func NewUploadFilesRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/fs/upload") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewUploadZipRequestWithBody generates requests for UploadZip with any type of body +func NewUploadZipRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/fs/upload_zip") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewStartFsWatchRequest calls the generic StartFsWatch builder with application/json body func NewStartFsWatchRequest(server string, body StartFsWatchJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader @@ -1682,6 +1794,12 @@ type ClientWithResponsesInterface interface { SetFilePermissionsWithResponse(ctx context.Context, body SetFilePermissionsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetFilePermissionsResponse, error) + // UploadFilesWithBodyWithResponse request with any body + UploadFilesWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadFilesResponse, error) + + // UploadZipWithBodyWithResponse request with any body + UploadZipWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadZipResponse, error) + // StartFsWatchWithBodyWithResponse request with any body StartFsWatchWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartFsWatchResponse, error) @@ -1957,6 +2075,54 @@ func (r SetFilePermissionsResponse) StatusCode() int { return 0 } +type UploadFilesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r UploadFilesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UploadFilesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type UploadZipResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r UploadZipResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UploadZipResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type StartFsWatchResponse struct { Body []byte HTTPResponse *http.Response @@ -2321,6 +2487,24 @@ func (c *ClientWithResponses) SetFilePermissionsWithResponse(ctx context.Context return ParseSetFilePermissionsResponse(rsp) } +// UploadFilesWithBodyWithResponse request with arbitrary body returning *UploadFilesResponse +func (c *ClientWithResponses) UploadFilesWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadFilesResponse, error) { + rsp, err := c.UploadFilesWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUploadFilesResponse(rsp) +} + +// UploadZipWithBodyWithResponse request with arbitrary body returning *UploadZipResponse +func (c *ClientWithResponses) UploadZipWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadZipResponse, error) { + rsp, err := c.UploadZipWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUploadZipResponse(rsp) +} + // StartFsWatchWithBodyWithResponse request with arbitrary body returning *StartFsWatchResponse func (c *ClientWithResponses) StartFsWatchWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartFsWatchResponse, error) { rsp, err := c.StartFsWatchWithBody(ctx, contentType, body, reqEditors...) @@ -2827,6 +3011,86 @@ func ParseSetFilePermissionsResponse(rsp *http.Response) (*SetFilePermissionsRes return response, nil } +// ParseUploadFilesResponse parses an HTTP response from a UploadFilesWithResponse call +func ParseUploadFilesResponse(rsp *http.Response) (*UploadFilesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UploadFilesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseUploadZipResponse parses an HTTP response from a UploadZipWithResponse call +func ParseUploadZipResponse(rsp *http.Response) (*UploadZipResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &UploadZipResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseStartFsWatchResponse parses an HTTP response from a StartFsWatchWithResponse call func ParseStartFsWatchResponse(rsp *http.Response) (*StartFsWatchResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -3215,6 +3479,12 @@ type ServerInterface interface { // Set file or directory permissions/ownership // (PUT /fs/set_file_permissions) SetFilePermissions(w http.ResponseWriter, r *http.Request) + // Upload one or more files + // (POST /fs/upload) + UploadFiles(w http.ResponseWriter, r *http.Request) + // Upload a zip archive and extract it + // (POST /fs/upload_zip) + UploadZip(w http.ResponseWriter, r *http.Request) // Watch a directory for changes // (POST /fs/watch) StartFsWatch(w http.ResponseWriter, r *http.Request) @@ -3308,6 +3578,18 @@ func (_ Unimplemented) SetFilePermissions(w http.ResponseWriter, r *http.Request w.WriteHeader(http.StatusNotImplemented) } +// Upload one or more files +// (POST /fs/upload) +func (_ Unimplemented) UploadFiles(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Upload a zip archive and extract it +// (POST /fs/upload_zip) +func (_ Unimplemented) UploadZip(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + // Watch a directory for changes // (POST /fs/watch) func (_ Unimplemented) StartFsWatch(w http.ResponseWriter, r *http.Request) { @@ -3571,6 +3853,34 @@ func (siw *ServerInterfaceWrapper) SetFilePermissions(w http.ResponseWriter, r * handler.ServeHTTP(w, r) } +// UploadFiles operation middleware +func (siw *ServerInterfaceWrapper) UploadFiles(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UploadFiles(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UploadZip operation middleware +func (siw *ServerInterfaceWrapper) UploadZip(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UploadZip(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // StartFsWatch operation middleware func (siw *ServerInterfaceWrapper) StartFsWatch(w http.ResponseWriter, r *http.Request) { @@ -3903,6 +4213,12 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Put(options.BaseURL+"/fs/set_file_permissions", wrapper.SetFilePermissions) }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/fs/upload", wrapper.UploadFiles) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/fs/upload_zip", wrapper.UploadZip) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/fs/watch", wrapper.StartFsWatch) }) @@ -4358,6 +4674,92 @@ func (response SetFilePermissions500JSONResponse) VisitSetFilePermissionsRespons return json.NewEncoder(w).Encode(response) } +type UploadFilesRequestObject struct { + Body *multipart.Reader +} + +type UploadFilesResponseObject interface { + VisitUploadFilesResponse(w http.ResponseWriter) error +} + +type UploadFiles201Response struct { +} + +func (response UploadFiles201Response) VisitUploadFilesResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil +} + +type UploadFiles400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response UploadFiles400JSONResponse) VisitUploadFilesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type UploadFiles404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response UploadFiles404JSONResponse) VisitUploadFilesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type UploadFiles500JSONResponse struct{ InternalErrorJSONResponse } + +func (response UploadFiles500JSONResponse) VisitUploadFilesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type UploadZipRequestObject struct { + Body *multipart.Reader +} + +type UploadZipResponseObject interface { + VisitUploadZipResponse(w http.ResponseWriter) error +} + +type UploadZip201Response struct { +} + +func (response UploadZip201Response) VisitUploadZipResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil +} + +type UploadZip400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response UploadZip400JSONResponse) VisitUploadZipResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type UploadZip404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response UploadZip404JSONResponse) VisitUploadZipResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type UploadZip500JSONResponse struct{ InternalErrorJSONResponse } + +func (response UploadZip500JSONResponse) VisitUploadZipResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type StartFsWatchRequestObject struct { Body *StartFsWatchJSONRequestBody } @@ -4827,6 +5229,12 @@ type StrictServerInterface interface { // Set file or directory permissions/ownership // (PUT /fs/set_file_permissions) SetFilePermissions(ctx context.Context, request SetFilePermissionsRequestObject) (SetFilePermissionsResponseObject, error) + // Upload one or more files + // (POST /fs/upload) + UploadFiles(ctx context.Context, request UploadFilesRequestObject) (UploadFilesResponseObject, error) + // Upload a zip archive and extract it + // (POST /fs/upload_zip) + UploadZip(ctx context.Context, request UploadZipRequestObject) (UploadZipResponseObject, error) // Watch a directory for changes // (POST /fs/watch) StartFsWatch(ctx context.Context, request StartFsWatchRequestObject) (StartFsWatchResponseObject, error) @@ -5180,6 +5588,68 @@ func (sh *strictHandler) SetFilePermissions(w http.ResponseWriter, r *http.Reque } } +// UploadFiles operation middleware +func (sh *strictHandler) UploadFiles(w http.ResponseWriter, r *http.Request) { + var request UploadFilesRequestObject + + if reader, err := r.MultipartReader(); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err)) + return + } else { + request.Body = reader + } + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UploadFiles(ctx, request.(UploadFilesRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UploadFiles") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UploadFilesResponseObject); ok { + if err := validResponse.VisitUploadFilesResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// UploadZip operation middleware +func (sh *strictHandler) UploadZip(w http.ResponseWriter, r *http.Request) { + var request UploadZipRequestObject + + if reader, err := r.MultipartReader(); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err)) + return + } else { + request.Body = reader + } + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.UploadZip(ctx, request.(UploadZipRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "UploadZip") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(UploadZipResponseObject); ok { + if err := validResponse.VisitUploadZipResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // StartFsWatch operation middleware func (sh *strictHandler) StartFsWatch(w http.ResponseWriter, r *http.Request) { var request StartFsWatchRequestObject @@ -5437,54 +5907,57 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", - "//+ThhMPQj4AAA==", + "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/lib/ziputil/ziputil.go b/server/lib/ziputil/ziputil.go new file mode 100644 index 00000000..6a1265ce --- /dev/null +++ b/server/lib/ziputil/ziputil.go @@ -0,0 +1,132 @@ +package ziputil + +import ( + "archive/zip" + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// ZipDir creates a zip file from a directory. +func ZipDir(sourceDir string) ([]byte, error) { + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + defer zipWriter.Close() + + err := filepath.Walk(sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + // Create a relative path + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return err + } + + // Skip the directory itself + if relPath == "." { + return nil + } + + // Create zip header + header, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + header.Name = relPath + + if info.IsDir() { + header.Name += "/" + } else { + header.Method = zip.Deflate + } + + writer, err := zipWriter.CreateHeader(header) + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + // Add file content + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + + _, err = io.Copy(writer, file) + return err + }) + if err != nil { + return nil, err + } + if err := zipWriter.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// Unzip extracts a zip file to the specified directory +func Unzip(zipFilePath, destDir string) error { + // Open the zip file + reader, err := zip.OpenReader(zipFilePath) + if err != nil { + return fmt.Errorf("failed to open zip file: %w", err) + } + defer reader.Close() + + // Create the destination directory if it doesn't exist + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %w", err) + } + // Extract each file + for _, file := range reader.File { + // Create the full destination path + destPath := filepath.Join(destDir, file.Name) + + // Check for directory traversal vulnerabilities + if !strings.HasPrefix(destPath, filepath.Clean(destDir)+string(os.PathSeparator)) { + return fmt.Errorf("illegal file path: %s", file.Name) + } + + // Handle directories + if file.FileInfo().IsDir() { + if err := os.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + continue + } + + // Create the containing directory if it doesn't exist + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + return fmt.Errorf("failed to create directory path: %w", err) + } + + // Open the file from the zip + fileReader, err := file.Open() + if err != nil { + return fmt.Errorf("failed to open file in zip: %w", err) + } + defer fileReader.Close() + + // Create the destination file + destFile, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) + if err != nil { + return fmt.Errorf("failed to create destination file (file mode %s): %w", file.Mode().String(), err) + } + defer destFile.Close() + + // Copy the contents + if _, err := io.Copy(destFile, fileReader); err != nil { + return fmt.Errorf("failed to extract file: %w", err) + } + } + + return nil +} diff --git a/server/lib/ziputil/ziputil_test.go b/server/lib/ziputil/ziputil_test.go new file mode 100644 index 00000000..832ada59 --- /dev/null +++ b/server/lib/ziputil/ziputil_test.go @@ -0,0 +1,115 @@ +package ziputil + +import ( + "archive/zip" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUnzipFile(t *testing.T) { + // Create a temporary directory for test files + sourceDir, err := os.MkdirTemp("", "zip-source-*") + require.NoError(t, err) + defer os.RemoveAll(sourceDir) + + // Create test files + testFiles := map[string]string{ + "app.py": "print('Hello, World!')", + "requirements.txt": "requests==2.26.0", + "utils/helpers.py": "def greet(): return 'Hello'", + "utils/__init__.py": "", + "data/sample.json": "{\"key\": \"value\"}", + } + + for path, content := range testFiles { + fullPath := filepath.Join(sourceDir, path) + err := os.MkdirAll(filepath.Dir(fullPath), 0755) + require.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), 0644) + require.NoError(t, err) + } + + // Create a zip file from the source directory + zipFilePath := filepath.Join(sourceDir, "test.zip") + zipFile, err := os.Create(zipFilePath) + require.NoError(t, err) + defer zipFile.Close() + + // Write zip content + zipContent, err := ZipDir(sourceDir) + require.NoError(t, err) + zipFile.Close() // Close to flush content + + // Create a new zipFile from the content for testing + tempZipFile, err := os.CreateTemp("", "test-*.zip") + require.NoError(t, err) + defer os.Remove(tempZipFile.Name()) + defer tempZipFile.Close() + + _, err = tempZipFile.Write(zipContent) + require.NoError(t, err) + tempZipFile.Close() + + // Create destination directory for unzipping + destDir, err := os.MkdirTemp("", "zip-dest-*") + require.NoError(t, err) + defer os.RemoveAll(destDir) + + // Test the UnzipFile function + err = Unzip(tempZipFile.Name(), destDir) + require.NoError(t, err) + + // Verify that all files exist in the extracted directory + for path, expectedContent := range testFiles { + extractedPath := filepath.Join(destDir, path) + require.FileExists(t, extractedPath) + + content, err := os.ReadFile(extractedPath) + require.NoError(t, err) + assert.Equal(t, expectedContent, string(content)) + } + + // Test handling of malicious zip entries (directory traversal) + maliciousZipPath := filepath.Join(sourceDir, "malicious.zip") + createMaliciousZip(t, maliciousZipPath) + + // Create destination directory for malicious unzipping + maliciousDestDir, err := os.MkdirTemp("", "zip-malicious-*") + require.NoError(t, err) + defer os.RemoveAll(maliciousDestDir) + + // This should fail with an illegal file path error + err = Unzip(maliciousZipPath, maliciousDestDir) + require.Error(t, err) + assert.Contains(t, err.Error(), "illegal file path") +} + +// createMaliciousZip creates a ZIP file with a path traversal attempt +func createMaliciousZip(t *testing.T, zipPath string) { + zipFile, err := os.Create(zipPath) + require.NoError(t, err) + defer zipFile.Close() + + zipWriter := zip.NewWriter(zipFile) + defer zipWriter.Close() + + // Create a file with path traversal attempt + traversalPath := "../../../../../etc/passwd" + fileHeader := &zip.FileHeader{ + Name: traversalPath, + Method: zip.Deflate, + } + + writer, err := zipWriter.CreateHeader(fileHeader) + require.NoError(t, err) + + _, err = writer.Write([]byte("malicious content")) + require.NoError(t, err) + + err = zipWriter.Close() + require.NoError(t, err) +} diff --git a/server/openapi.yaml b/server/openapi.yaml index d56dbaa5..f4b5bb08 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -218,6 +218,72 @@ paths: "500": $ref: "#/components/responses/InternalError" + /fs/upload: + post: + summary: Upload one or more files + description: Allows uploading single or multiple files to the remote filesystem. + operationId: uploadFiles + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + files: + type: array + items: + type: object + properties: + file: + type: string + format: binary + dest_path: + type: string + description: Absolute destination path to write the file. + pattern: "^/.*" + required: [file, dest_path] + required: [files] + responses: + "201": + description: Files uploaded successfully + "400": + $ref: "#/components/responses/BadRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + + /fs/upload_zip: + post: + summary: Upload a zip archive and extract it + description: Upload a zip file and extract its contents to the specified destination path. + operationId: uploadZip + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + zip_file: + type: string + format: binary + dest_path: + type: string + description: Absolute destination directory to extract the archive to. + pattern: "^/.*" + required: [zip_file, dest_path] + responses: + "201": + description: Zip uploaded and extracted successfully + "400": + $ref: "#/components/responses/BadRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + /fs/list_files: get: summary: List files in a directory From 0b58b78be5e581cded1fb868fc5a923dd898178e Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 17:30:44 +0000 Subject: [PATCH 09/24] better logs for headless image --- images/chromium-headless/image/Dockerfile | 1 - images/chromium-headless/image/wrapper.sh | 45 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 3 deletions(-) diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index eaba38cb..3d3240cf 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -52,7 +52,6 @@ RUN apt-get -yqq purge upower || true && rm -rf /var/lib/apt/lists/* RUN useradd -m -s /bin/bash kernel # Xvfb helper and supervisor-managed start scripts -COPY ./xvfb_startup.sh /usr/bin/xvfb_startup.sh COPY ./start-chromium.sh /images/chromium-headless/image/start-chromium.sh COPY ./start-xvfb.sh /images/chromium-headless/image/start-xvfb.sh RUN chmod +x /images/chromium-headless/image/start-chromium.sh /images/chromium-headless/image/start-xvfb.sh diff --git a/images/chromium-headless/image/wrapper.sh b/images/chromium-headless/image/wrapper.sh index 369e5bb9..c43027e8 100755 --- a/images/chromium-headless/image/wrapper.sh +++ b/images/chromium-headless/image/wrapper.sh @@ -80,6 +80,41 @@ done # Ensure correct ownership (ignore errors if already correct) chown -R kernel:kernel /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true +# ----------------------------------------------------------------------------- +# Dynamic log aggregation for /var/log/supervisord ----------------------------- +# ----------------------------------------------------------------------------- +# Tails any existing and future files under /var/log/supervisord, +# prefixing each line with the relative filepath, e.g. [chromium] ... +start_dynamic_log_aggregator() { + echo "[wrapper] Starting dynamic log aggregator for /var/log/supervisord" + ( + declare -A tailed_files=() + start_tail() { + local f="$1" + [[ -f "$f" ]] || return 0 + [[ -n "${tailed_files[$f]:-}" ]] && return 0 + local label="${f#/var/log/supervisord/}" + # Tie tails to this subshell lifetime so they exit when we stop it + tail --pid="$$" -n +1 -F "$f" 2>/dev/null | sed -u "s/^/[${label}] /" & + tailed_files[$f]=1 + } + # Periodically scan for new *.log files without extra dependencies + while true; do + while IFS= read -r -d '' f; do + start_tail "$f" + done < <(find /var/log/supervisord -type f -print0 2>/dev/null || true) + sleep 1 + done + ) & + tail_pids+=("$!") +} + +# Track background tailing processes for cleanup +tail_pids=() + +# Start log aggregator early so we see supervisor and service logs as they appear +start_dynamic_log_aggregator + # Export common env used by services export DISPLAY=:1 export HEIGHT=${HEIGHT:-768} @@ -95,6 +130,12 @@ cleanup () { supervisorctl -c /etc/supervisor/supervisord.conf stop xvfb || true supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true supervisorctl -c /etc/supervisor/supervisord.conf stop kernel-images-api || true + # Stop log tailers + if [[ -n "${tail_pids[*]:-}" ]]; then + for tp in "${tail_pids[@]}"; do + kill -TERM "$tp" 2>/dev/null || true + done + fi } trap cleanup TERM INT @@ -155,6 +196,6 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then done fi -# Keep running while services are supervised; stream supervisor logs -tail -n +1 -F /var/log/supervisord/* 2>/dev/null & +echo "[wrapper] startup complete!" +# Keep the container running while streaming logs wait From c261707f47c886f0cca08ac08e8a7f0ea82642b9 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 20:14:10 +0000 Subject: [PATCH 10/24] log tailing, process exec'ing --- server/Makefile | 2 +- server/cmd/api/api/api.go | 5 + server/cmd/api/api/logs.go | 129 + server/cmd/api/api/logs_test.go | 65 + server/cmd/api/api/process.go | 438 ++++ server/cmd/api/api/process_test.go | 237 ++ server/go.mod | 2 +- server/lib/oapi/oapi.go | 3718 ++++++++++++++++++++++------ server/openapi.yaml | 370 +++ 9 files changed, 4175 insertions(+), 791 deletions(-) create mode 100644 server/cmd/api/api/logs.go create mode 100644 server/cmd/api/api/logs_test.go create mode 100644 server/cmd/api/api/process.go create mode 100644 server/cmd/api/api/process_test.go diff --git a/server/Makefile b/server/Makefile index a45d3203..83e78c96 100644 --- a/server/Makefile +++ b/server/Makefile @@ -23,7 +23,7 @@ oapi-generate: $(OAPI_CODEGEN) openapi-down-convert --input openapi.yaml --output openapi-3.0.yaml $(OAPI_CODEGEN) -config ./oapi-codegen.yaml ./openapi-3.0.yaml @echo "Fixing oapi-codegen issue https://github.com/oapi-codegen/oapi-codegen/issues/1764..." - go run ./scripts/oapi/patch_sse_methods.go -file ./lib/oapi/oapi.go -expected-replacements 1 + go run ./scripts/oapi/patch_sse_methods.go -file ./lib/oapi/oapi.go -expected-replacements 3 go fmt ./lib/oapi/oapi.go go mod tidy diff --git a/server/cmd/api/api/api.go b/server/cmd/api/api/api.go index 21f5794c..36aa3acb 100644 --- a/server/cmd/api/api/api.go +++ b/server/cmd/api/api/api.go @@ -22,6 +22,10 @@ type ApiService struct { // Filesystem watch management watchMu sync.RWMutex watches map[string]*fsWatch + + // Process management + procMu sync.RWMutex + procs map[string]*processHandle } var _ oapi.StrictServerInterface = (*ApiService)(nil) @@ -39,6 +43,7 @@ func New(recordManager recorder.RecordManager, factory recorder.FFmpegRecorderFa factory: factory, defaultRecorderID: "default", watches: make(map[string]*fsWatch), + procs: make(map[string]*processHandle), }, nil } diff --git a/server/cmd/api/api/logs.go b/server/cmd/api/api/logs.go new file mode 100644 index 00000000..42cdf085 --- /dev/null +++ b/server/cmd/api/api/logs.go @@ -0,0 +1,129 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "time" + + oapi "github.com/onkernel/kernel-images/server/lib/oapi" +) + +// LogsStream implements Server-Sent Events log streaming. +// (GET /logs/stream) +func (s *ApiService) LogsStream(ctx context.Context, request oapi.LogsStreamRequestObject) (oapi.LogsStreamResponseObject, error) { + // Only path-based streaming is implemented. Supervisor streaming can be added later. + src := string(request.Params.Source) + follow := true + if request.Params.Follow != nil { + follow = *request.Params.Follow + } + + var logPath string + if src == "path" { + if request.Params.Path != nil { + logPath = *request.Params.Path + } + } + + pr, pw := io.Pipe() + + go func() { + defer pw.Close() + + if logPath == "" || !filepath.IsAbs(logPath) { + _ = writeSSELogEvent(pw, oapi.LogEvent{Timestamp: time.Now(), Message: "logs source not available"}) + return + } + + f, err := os.Open(logPath) + if err != nil { + _ = writeSSELogEvent(pw, oapi.LogEvent{Timestamp: time.Now(), Message: "failed to open log path"}) + return + } + defer f.Close() + + var offset int64 = 0 + if follow { + if st, err := f.Stat(); err == nil { + offset = st.Size() + } + } + + var remainder []byte + buf := make([]byte, 16*1024) + + ticker := time.NewTicker(50 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + st, err := f.Stat() + if err != nil { + return + } + size := st.Size() + if size < offset { + offset = size + } + if size == offset { + continue + } + toRead := size - offset + for toRead > 0 { + if int64(len(buf)) > toRead { + buf = buf[:toRead] + } + n, err := f.ReadAt(buf, offset) + if n > 0 { + offset += int64(n) + toRead -= int64(n) + chunk := append(remainder, buf[:n]...) + for { + if i := bytes.IndexByte(chunk, '\n'); i >= 0 { + line := chunk[:i] + if len(line) > 0 { + _ = writeSSELogEvent(pw, oapi.LogEvent{Timestamp: time.Now(), Message: string(line)}) + } + chunk = chunk[i+1:] + continue + } + break + } + remainder = chunk + } + if err != nil { + break + } + } + } + } + }() + + headers := oapi.LogsStream200ResponseHeaders{XSSEContentType: "application/json"} + return oapi.LogsStream200TexteventStreamResponse{Body: pr, Headers: headers, ContentLength: 0}, nil +} + +func writeSSELogEvent(w io.Writer, ev oapi.LogEvent) error { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(ev); err != nil { + return err + } + line := bytes.TrimRight(buf.Bytes(), "\n") + if _, err := w.Write([]byte("data: ")); err != nil { + return err + } + if _, err := w.Write(line); err != nil { + return err + } + if _, err := w.Write([]byte("\n\n")); err != nil { + return err + } + return nil +} diff --git a/server/cmd/api/api/logs_test.go b/server/cmd/api/api/logs_test.go new file mode 100644 index 00000000..525b868d --- /dev/null +++ b/server/cmd/api/api/logs_test.go @@ -0,0 +1,65 @@ +package api + +import ( + "bufio" + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + oapi "github.com/onkernel/kernel-images/server/lib/oapi" +) + +func TestLogsStream_PathFollow(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + svc := &ApiService{} + + // create temp log file + tmpDir := t.TempDir() + logPath := filepath.Join(tmpDir, "app.log") + if err := os.WriteFile(logPath, []byte("initial\n"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + // start streaming with follow=true from end of file + follow := true + resp, err := svc.LogsStream(ctx, oapi.LogsStreamRequestObject{Params: oapi.LogsStreamParams{Source: "path", Follow: &follow, Path: &logPath}}) + if err != nil { + t.Fatalf("LogsStream error: %v", err) + } + r200, ok := resp.(oapi.LogsStream200TexteventStreamResponse) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + + // write another line after starting + go func() { + time.Sleep(100 * time.Millisecond) + f, _ := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY, 0o644) + defer f.Close() + _, _ = f.WriteString("hello world\n") + }() + + reader := bufio.NewReader(r200.Body) + deadline := time.Now().Add(2 * time.Second) + for { + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for SSE line") + } + line, err := reader.ReadString('\n') + if err != nil { + continue + } + if strings.HasPrefix(line, "data: ") { + payload := strings.TrimPrefix(line, "data: ") + if strings.Contains(payload, "hello world") { + break + } + } + } +} diff --git a/server/cmd/api/api/process.go b/server/cmd/api/api/process.go new file mode 100644 index 00000000..c8a67a94 --- /dev/null +++ b/server/cmd/api/api/process.go @@ -0,0 +1,438 @@ +package api + +import ( + "bufio" + "bytes" + "context" + "encoding/base64" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" + "github.com/onkernel/kernel-images/server/lib/logger" + oapi "github.com/onkernel/kernel-images/server/lib/oapi" +) + +type processHandle struct { + id openapi_types.UUID + pid int + cmd *exec.Cmd + started time.Time + exitCode *int + stdin io.WriteCloser + stdout io.ReadCloser + stderr io.ReadCloser + outCh chan oapi.ProcessStreamEvent + doneCh chan struct{} + mu sync.RWMutex +} + +func (h *processHandle) state() string { + h.mu.RLock() + defer h.mu.RUnlock() + if h.exitCode != nil { + return "exited" + } + return "running" +} + +func (h *processHandle) setExited(code int) { + h.mu.Lock() + if h.exitCode == nil { + h.exitCode = &code + } + h.mu.Unlock() +} + +func buildCmd(body *oapi.ProcessExecRequest) (*exec.Cmd, error) { + if body == nil || body.Command == "" { + return nil, errors.New("command required") + } + var args []string + if body.Args != nil { + args = append(args, (*body.Args)...) + } + cmd := exec.Command(body.Command, args...) + if body.Cwd != nil && *body.Cwd != "" { + cmd.Dir = *body.Cwd + // Ensure absolute if provided + if !filepath.IsAbs(cmd.Dir) { + // make relative to current working directory + wd, _ := os.Getwd() + cmd.Dir = filepath.Join(wd, cmd.Dir) + } + } + // Build environment + envMap := map[string]string{} + for _, kv := range os.Environ() { + if i := strings.IndexByte(kv, '='); i > 0 { + envMap[kv[:i]] = kv[i+1:] + } + } + if body.Env != nil { + for k, v := range *body.Env { + envMap[k] = v + } + } + env := make([]string, 0, len(envMap)) + for k, v := range envMap { + env = append(env, k+"="+v) + } + cmd.Env = env + return cmd, nil +} + +// Execute a command synchronously (optional streaming) +// (POST /process/exec) +func (s *ApiService) ProcessExec(ctx context.Context, request oapi.ProcessExecRequestObject) (oapi.ProcessExecResponseObject, error) { + log := logger.FromContext(ctx) + if request.Body == nil { + return oapi.ProcessExec400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body required"}}, nil + } + // Streaming over this endpoint is not supported by the current API definition + if request.Body.Stream != nil && *request.Body.Stream { + return oapi.ProcessExec400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "streaming not supported for /process/exec"}}, nil + } + + cmd, err := buildCmd((*oapi.ProcessExecRequest)(request.Body)) + if err != nil { + return oapi.ProcessExec400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}}, nil + } + + var stdoutBuf, stderrBuf bytes.Buffer + cmd.Stdout = &stdoutBuf + cmd.Stderr = &stderrBuf + + // Handle timeout if provided + start := time.Now() + var cancel context.CancelFunc + if request.Body.TimeoutSec != nil && *request.Body.TimeoutSec > 0 { + ctx, cancel = context.WithTimeout(ctx, time.Duration(*request.Body.TimeoutSec)*time.Second) + defer cancel() + } + if err := cmd.Start(); err != nil { + log.Error("failed to start process", "err", err) + return oapi.ProcessExec500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start process"}}, nil + } + + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-ctx.Done(): + _ = cmd.Process.Kill() + <-done // ensure wait returns + return oapi.ProcessExec500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "process timed out"}}, nil + case err := <-done: + // proceed + _ = err + } + durationMs := int(time.Since(start) / time.Millisecond) + exitCode := 0 + if cmd.ProcessState != nil { + if status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok { + exitCode = status.ExitStatus() + } + } + + resp := oapi.ProcessExec200JSONResponse{ + ExitCode: &exitCode, + StdoutB64: ptrOf(base64.StdEncoding.EncodeToString(stdoutBuf.Bytes())), + StderrB64: ptrOf(base64.StdEncoding.EncodeToString(stderrBuf.Bytes())), + DurationMs: &durationMs, + } + return resp, nil +} + +// Execute a command asynchronously +// (POST /process/spawn) +func (s *ApiService) ProcessSpawn(ctx context.Context, request oapi.ProcessSpawnRequestObject) (oapi.ProcessSpawnResponseObject, error) { + log := logger.FromContext(ctx) + if request.Body == nil { + return oapi.ProcessSpawn400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body required"}}, nil + } + // Build from ProcessExecRequest shape + execReq := oapi.ProcessExecRequest{ + Command: request.Body.Command, + Args: request.Body.Args, + Cwd: request.Body.Cwd, + Env: request.Body.Env, + AsUser: request.Body.AsUser, + AsRoot: request.Body.AsRoot, + TimeoutSec: request.Body.TimeoutSec, + } + cmd, err := buildCmd(&execReq) + if err != nil { + return oapi.ProcessSpawn400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: err.Error()}}, nil + } + + stdout, err := cmd.StdoutPipe() + if err != nil { + return oapi.ProcessSpawn500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to open stdout"}}, nil + } + stderr, err := cmd.StderrPipe() + if err != nil { + return oapi.ProcessSpawn500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to open stderr"}}, nil + } + stdin, err := cmd.StdinPipe() + if err != nil { + return oapi.ProcessSpawn500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to open stdin"}}, nil + } + if err := cmd.Start(); err != nil { + log.Error("failed to start process", "err", err) + return oapi.ProcessSpawn500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to start process"}}, nil + } + + id := openapi_types.UUID(uuid.New()) + h := &processHandle{ + id: id, + pid: cmd.Process.Pid, + cmd: cmd, + started: time.Now(), + stdin: stdin, + stdout: stdout, + stderr: stderr, + outCh: make(chan oapi.ProcessStreamEvent, 256), + doneCh: make(chan struct{}), + } + + // Store handle + s.procMu.Lock() + if s.procs == nil { + s.procs = make(map[string]*processHandle) + } + s.procs[id.String()] = h + s.procMu.Unlock() + + // Reader goroutines + go func() { + reader := bufio.NewReader(stdout) + buf := make([]byte, 4096) + for { + n, err := reader.Read(buf) + if n > 0 { + data := base64.StdEncoding.EncodeToString(buf[:n]) + stream := oapi.ProcessStreamEventStream("stdout") + h.outCh <- oapi.ProcessStreamEvent{Stream: &stream, DataB64: &data} + } + if err != nil { + if !errors.Is(err, io.EOF) { + // ignore + } + break + } + } + }() + + go func() { + reader := bufio.NewReader(stderr) + buf := make([]byte, 4096) + for { + n, err := reader.Read(buf) + if n > 0 { + data := base64.StdEncoding.EncodeToString(buf[:n]) + stream := oapi.ProcessStreamEventStream("stderr") + h.outCh <- oapi.ProcessStreamEvent{Stream: &stream, DataB64: &data} + } + if err != nil { + if !errors.Is(err, io.EOF) { + } + break + } + } + }() + + // Waiter goroutine + go func() { + err := cmd.Wait() + code := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { + code = status.ExitStatus() + } + } + } else if cmd.ProcessState != nil { + if status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok { + code = status.ExitStatus() + } + } + h.setExited(code) + // Send exit event + evt := oapi.ProcessStreamEventEvent("exit") + h.outCh <- oapi.ProcessStreamEvent{Event: &evt, ExitCode: &code} + close(h.doneCh) + }() + + startedAt := h.started + pid := h.pid + return oapi.ProcessSpawn200JSONResponse{ + ProcessId: &id, + Pid: &pid, + StartedAt: &startedAt, + }, nil +} + +// Send signal to process +// (POST /process/{process_id}/kill) +func (s *ApiService) ProcessKill(ctx context.Context, request oapi.ProcessKillRequestObject) (oapi.ProcessKillResponseObject, error) { + log := logger.FromContext(ctx) + id := request.ProcessId.String() + s.procMu.RLock() + h, ok := s.procs[id] + s.procMu.RUnlock() + if !ok { + return oapi.ProcessKill404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "process not found"}}, nil + } + if request.Body == nil { + return oapi.ProcessKill400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body required"}}, nil + } + // Map signal + var sig syscall.Signal + switch request.Body.Signal { + case "TERM": + sig = syscall.SIGTERM + case "KILL": + sig = syscall.SIGKILL + case "INT": + sig = syscall.SIGINT + case "HUP": + sig = syscall.SIGHUP + default: + return oapi.ProcessKill400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid signal"}}, nil + } + if h.cmd.Process == nil { + return oapi.ProcessKill404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "process not running"}}, nil + } + if err := h.cmd.Process.Signal(sig); err != nil { + log.Error("failed to signal process", "err", err) + return oapi.ProcessKill500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to signal process"}}, nil + } + return oapi.ProcessKill200JSONResponse(oapi.OkResponse{Ok: true}), nil +} + +// Get process status +// (GET /process/{process_id}/status) +func (s *ApiService) ProcessStatus(ctx context.Context, request oapi.ProcessStatusRequestObject) (oapi.ProcessStatusResponseObject, error) { + id := request.ProcessId.String() + s.procMu.RLock() + h, ok := s.procs[id] + s.procMu.RUnlock() + if !ok { + return oapi.ProcessStatus404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "process not found"}}, nil + } + stateStr := h.state() + state := oapi.ProcessStatusState(stateStr) + var exitCode *int + h.mu.RLock() + if h.exitCode != nil { + v := *h.exitCode + exitCode = &v + } + pid := h.pid + h.mu.RUnlock() + // Best-effort memory stats via /proc + var memBytes int + if stateStr == "running" && pid > 0 { + if b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/status"); err == nil { + // Parse VmRSS: 123 kB + for _, line := range strings.Split(string(b), "\n") { + if strings.HasPrefix(line, "VmRSS:") { + fields := strings.Fields(line) + if len(fields) >= 2 { + if v, err := strconv.Atoi(fields[1]); err == nil { + // fields[2] is likely kB + memBytes = v * 1024 + } + } + break + } + } + } + } + cpuPct := float32(0) + resp := oapi.ProcessStatus200JSONResponse{State: &state, ExitCode: exitCode, CpuPct: &cpuPct} + if memBytes > 0 { + resp.MemBytes = ptrOf(memBytes) + } + return resp, nil +} + +// Write to process stdin +// (POST /process/{process_id}/stdin) +func (s *ApiService) ProcessStdin(ctx context.Context, request oapi.ProcessStdinRequestObject) (oapi.ProcessStdinResponseObject, error) { + id := request.ProcessId.String() + s.procMu.RLock() + h, ok := s.procs[id] + s.procMu.RUnlock() + if !ok { + return oapi.ProcessStdin404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "process not found"}}, nil + } + if request.Body == nil { + return oapi.ProcessStdin400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "request body required"}}, nil + } + data, err := base64.StdEncoding.DecodeString(request.Body.DataB64) + if err != nil { + return oapi.ProcessStdin400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "invalid base64"}}, nil + } + n, err := h.stdin.Write(data) + if err != nil { + return oapi.ProcessStdin500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to write to stdin"}}, nil + } + return oapi.ProcessStdin200JSONResponse{WrittenBytes: ptrOf(n)}, nil +} + +// Stream process stdout/stderr (SSE) +// (GET /process/{process_id}/stdout/stream) +func (s *ApiService) ProcessStdoutStream(ctx context.Context, request oapi.ProcessStdoutStreamRequestObject) (oapi.ProcessStdoutStreamResponseObject, error) { + log := logger.FromContext(ctx) + id := request.ProcessId.String() + s.procMu.RLock() + h, ok := s.procs[id] + s.procMu.RUnlock() + if !ok { + return oapi.ProcessStdoutStream404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "process not found"}}, nil + } + + pr, pw := io.Pipe() + go func() { + defer pw.Close() + for { + select { + case evt := <-h.outCh: + // Write SSE: data: \n\n + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(evt); err != nil { + log.Error("failed to marshal event", "err", err) + return + } + line := bytes.TrimRight(buf.Bytes(), "\n") + if _, err := pw.Write([]byte("data: ")); err != nil { + return + } + if _, err := pw.Write(line); err != nil { + return + } + if _, err := pw.Write([]byte("\n\n")); err != nil { + return + } + case <-h.doneCh: + return + } + } + }() + + headers := oapi.ProcessStdoutStream200ResponseHeaders{XSSEContentType: "application/json"} + return oapi.ProcessStdoutStream200TexteventStreamResponse{Body: pr, Headers: headers, ContentLength: 0}, nil +} + +func ptrOf[T any](v T) *T { return &v } diff --git a/server/cmd/api/api/process_test.go b/server/cmd/api/api/process_test.go new file mode 100644 index 00000000..e1cb330f --- /dev/null +++ b/server/cmd/api/api/process_test.go @@ -0,0 +1,237 @@ +package api + +import ( + "bufio" + "context" + "encoding/base64" + "encoding/json" + "io" + "strings" + "testing" + "time" + + "github.com/google/uuid" + openapi_types "github.com/oapi-codegen/runtime/types" + oapi "github.com/onkernel/kernel-images/server/lib/oapi" +) + +func TestProcessExec(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{procs: make(map[string]*processHandle)} + + cmd := "sh" + args := []string{"-c", "echo -n out; echo -n err 1>&2; exit 3"} + body := &oapi.ProcessExecRequest{Command: cmd, Args: &args} + resp, err := svc.ProcessExec(ctx, oapi.ProcessExecRequestObject{Body: body}) + if err != nil { + t.Fatalf("ProcessExec error: %v", err) + } + r200, ok := resp.(oapi.ProcessExec200JSONResponse) + if !ok { + t.Fatalf("unexpected resp type: %T", resp) + } + if r200.ExitCode == nil || *r200.ExitCode != 3 { + t.Fatalf("exit code mismatch: %+v", r200.ExitCode) + } + if r200.StdoutB64 == nil || r200.StderrB64 == nil { + t.Fatalf("missing stdout/stderr in response") + } + out, _ := base64.StdEncoding.DecodeString(*r200.StdoutB64) + errB, _ := base64.StdEncoding.DecodeString(*r200.StderrB64) + if string(out) != "out" || string(errB) != "err" { + t.Fatalf("stdout/stderr mismatch: %q %q", string(out), string(errB)) + } +} + +func TestProcessSpawnStatusAndStream(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{procs: make(map[string]*processHandle)} + + // Spawn a short-lived process that emits stdout and stderr then exits + cmd := "sh" + args := []string{"-c", "printf ABC; sleep 0.05; printf DEF 1>&2; sleep 0.05; exit 0"} + body := &oapi.ProcessSpawnRequest{Command: cmd, Args: &args} + spawnResp, err := svc.ProcessSpawn(ctx, oapi.ProcessSpawnRequestObject{Body: body}) + if err != nil { + t.Fatalf("ProcessSpawn error: %v", err) + } + s200, ok := spawnResp.(oapi.ProcessSpawn200JSONResponse) + if !ok || s200.ProcessId == nil || s200.Pid == nil { + t.Fatalf("unexpected spawn resp: %+v", spawnResp) + } + + // Status should be running initially (may race to exited; tolerate both by not asserting) + statusResp, err := svc.ProcessStatus(ctx, oapi.ProcessStatusRequestObject{ProcessId: *s200.ProcessId}) + if err != nil { + t.Fatalf("ProcessStatus error: %v", err) + } + if _, ok := statusResp.(oapi.ProcessStatus200JSONResponse); !ok { + t.Fatalf("unexpected status resp: %T", statusResp) + } + + // Start stream reader and collect at least two data events and one exit event + streamResp, err := svc.ProcessStdoutStream(ctx, oapi.ProcessStdoutStreamRequestObject{ProcessId: *s200.ProcessId}) + if err != nil { + t.Fatalf("StdoutStream error: %v", err) + } + st200, ok := streamResp.(oapi.ProcessStdoutStream200TexteventStreamResponse) + if !ok { + t.Fatalf("unexpected stream resp: %T", streamResp) + } + + reader := bufio.NewReader(st200.Body) + var gotStdout, gotStderr, gotExit bool + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && !(gotStdout && gotStderr && gotExit) { + line, err := reader.ReadString('\n') + if err != nil { + if err == io.EOF { + break + } + t.Fatalf("read SSE line: %v", err) + } + if !strings.HasPrefix(line, "data: ") { + continue + } + payload := strings.TrimSpace(strings.TrimPrefix(line, "data: ")) + var evt oapi.ProcessStreamEvent + if err := json.Unmarshal([]byte(payload), &evt); err != nil { + t.Fatalf("unmarshal event: %v", err) + } + if evt.Stream != nil && *evt.Stream == "stdout" && evt.DataB64 != nil { + b, _ := base64.StdEncoding.DecodeString(*evt.DataB64) + if strings.Contains(string(b), "ABC") { + gotStdout = true + } + } + if evt.Stream != nil && *evt.Stream == "stderr" && evt.DataB64 != nil { + b, _ := base64.StdEncoding.DecodeString(*evt.DataB64) + if strings.Contains(string(b), "DEF") { + gotStderr = true + } + } + if evt.Event != nil && *evt.Event == "exit" { + gotExit = true + } + // consume blank line + _, _ = reader.ReadString('\n') + } + if !(gotStdout && gotStderr && gotExit) { + t.Fatalf("missing events: stdout=%v stderr=%v exit=%v", gotStdout, gotStderr, gotExit) + } +} + +func TestProcessStdinAndExit(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{procs: make(map[string]*processHandle)} + + // Spawn a process that reads exactly 3 bytes then exits + cmd := "sh" + args := []string{"-c", "dd of=/dev/null bs=1 count=3 status=none"} + body := &oapi.ProcessSpawnRequest{Command: cmd, Args: &args} + spawnResp, err := svc.ProcessSpawn(ctx, oapi.ProcessSpawnRequestObject{Body: body}) + if err != nil { + t.Fatalf("ProcessSpawn error: %v", err) + } + s200, ok := spawnResp.(oapi.ProcessSpawn200JSONResponse) + if !ok || s200.ProcessId == nil { + t.Fatalf("unexpected spawn resp: %T", spawnResp) + } + + // Write 3 bytes + data := base64.StdEncoding.EncodeToString([]byte("xyz")) + stdinResp, err := svc.ProcessStdin(ctx, oapi.ProcessStdinRequestObject{ProcessId: *s200.ProcessId, Body: &oapi.ProcessStdinRequest{DataB64: data}}) + if err != nil { + t.Fatalf("ProcessStdin error: %v", err) + } + st200, ok := stdinResp.(oapi.ProcessStdin200JSONResponse) + if !ok || st200.WrittenBytes == nil || *st200.WrittenBytes != 3 { + t.Fatalf("unexpected stdin resp: %+v", stdinResp) + } + + // Wait for exit via status polling + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + resp, err := svc.ProcessStatus(ctx, oapi.ProcessStatusRequestObject{ProcessId: *s200.ProcessId}) + if err != nil { + t.Fatalf("ProcessStatus error: %v", err) + } + sr, ok := resp.(oapi.ProcessStatus200JSONResponse) + if !ok { + t.Fatalf("unexpected status resp: %T", resp) + } + if sr.State != nil && *sr.State == "exited" { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("process did not exit in time") +} + +func TestProcessKill(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{procs: make(map[string]*processHandle)} + + cmd := "sh" + args := []string{"-c", "sleep 5"} + body := &oapi.ProcessSpawnRequest{Command: cmd, Args: &args} + spawnResp, err := svc.ProcessSpawn(ctx, oapi.ProcessSpawnRequestObject{Body: body}) + if err != nil { + t.Fatalf("ProcessSpawn error: %v", err) + } + s200, ok := spawnResp.(oapi.ProcessSpawn200JSONResponse) + if !ok || s200.ProcessId == nil { + t.Fatalf("unexpected spawn resp: %T", spawnResp) + } + + // Send KILL + killBody := &oapi.ProcessKillRequest{Signal: "KILL"} + killResp, err := svc.ProcessKill(ctx, oapi.ProcessKillRequestObject{ProcessId: *s200.ProcessId, Body: killBody}) + if err != nil { + t.Fatalf("ProcessKill error: %v", err) + } + if _, ok := killResp.(oapi.ProcessKill200JSONResponse); !ok { + t.Fatalf("unexpected kill resp: %T", killResp) + } + + // Verify exited + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + resp, err := svc.ProcessStatus(ctx, oapi.ProcessStatusRequestObject{ProcessId: *s200.ProcessId}) + if err != nil { + t.Fatalf("ProcessStatus error: %v", err) + } + sr, ok := resp.(oapi.ProcessStatus200JSONResponse) + if !ok { + t.Fatalf("unexpected status resp: %T", resp) + } + if sr.State != nil && *sr.State == "exited" { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("process not killed in time") +} + +func TestProcessNotFoundRoutes(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{procs: make(map[string]*processHandle)} + + // random id that will not exist + id := openapi_types.UUID(uuid.New()) + if resp, _ := svc.ProcessStatus(ctx, oapi.ProcessStatusRequestObject{ProcessId: id}); resp == nil { + t.Fatalf("expected a response") + } else if _, ok := resp.(oapi.ProcessStatus404JSONResponse); !ok { + t.Fatalf("expected 404, got %T", resp) + } + if resp, _ := svc.ProcessStdoutStream(ctx, oapi.ProcessStdoutStreamRequestObject{ProcessId: id}); resp == nil { + t.Fatalf("expected a response") + } else if _, ok := resp.(oapi.ProcessStdoutStream404JSONResponse); !ok { + t.Fatalf("expected 404, got %T", resp) + } +} diff --git a/server/go.mod b/server/go.mod index fbbc9592..036553b8 100644 --- a/server/go.mod +++ b/server/go.mod @@ -7,6 +7,7 @@ require ( github.com/getkin/kin-openapi v0.132.0 github.com/ghodss/yaml v1.0.0 github.com/go-chi/chi/v5 v5.2.1 + github.com/google/uuid v1.5.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/nrednav/cuid2 v1.1.0 github.com/oapi-codegen/runtime v1.1.1 @@ -19,7 +20,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/google/uuid v1.5.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 4f0ab215..8d8d1353 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -49,6 +49,37 @@ const ( WRITE FileSystemEventType = "WRITE" ) +// Defines values for ProcessKillRequestSignal. +const ( + HUP ProcessKillRequestSignal = "HUP" + INT ProcessKillRequestSignal = "INT" + KILL ProcessKillRequestSignal = "KILL" + TERM ProcessKillRequestSignal = "TERM" +) + +// Defines values for ProcessStatusState. +const ( + Exited ProcessStatusState = "exited" + Running ProcessStatusState = "running" +) + +// Defines values for ProcessStreamEventEvent. +const ( + Exit ProcessStreamEventEvent = "exit" +) + +// Defines values for ProcessStreamEventStream. +const ( + Stderr ProcessStreamEventStream = "stderr" + Stdout ProcessStreamEventStream = "stdout" +) + +// Defines values for LogsStreamParamsSource. +const ( + Path LogsStreamParamsSource = "path" + Supervisor LogsStreamParamsSource = "supervisor" +) + // ClickMouseRequest defines model for ClickMouseRequest. type ClickMouseRequest struct { // Button Mouse button to interact with @@ -144,6 +175,15 @@ type FileSystemEventType string // ListFiles Array of file or directory information entries. type ListFiles = []FileInfo +// LogEvent A log entry from the application. +type LogEvent struct { + // Message Log message text. + Message string `json:"message"` + + // Timestamp Time the log entry was produced. + Timestamp time.Time `json:"timestamp"` +} + // MoveMouseRequest defines model for MoveMouseRequest. type MoveMouseRequest struct { // HoldKeys Modifier keys to hold during the move @@ -165,6 +205,153 @@ type MovePathRequest struct { SrcPath string `json:"src_path"` } +// OkResponse Generic OK response. +type OkResponse struct { + // Ok Indicates success. + Ok bool `json:"ok"` +} + +// ProcessExecRequest Request to execute a command synchronously. +type ProcessExecRequest struct { + // Args Command arguments. + Args *[]string `json:"args,omitempty"` + + // AsRoot Run the process with root privileges. + AsRoot *bool `json:"as_root,omitempty"` + + // AsUser Run the process as this user. + AsUser *string `json:"as_user"` + + // Command Executable or shell command to run. + Command string `json:"command"` + + // Cwd Working directory (absolute path) to run the command in. + Cwd *string `json:"cwd"` + + // Env Environment variables to set for the process. + Env *map[string]string `json:"env,omitempty"` + + // Stream If true, stream output via SSE instead of returning complete buffers. + Stream *bool `json:"stream,omitempty"` + + // TimeoutSec Maximum execution time in seconds. + TimeoutSec *int `json:"timeout_sec"` +} + +// ProcessExecResult Result of a synchronous command execution. +type ProcessExecResult struct { + // DurationMs Execution duration in milliseconds. + DurationMs *int `json:"duration_ms,omitempty"` + + // ExitCode Process exit code. + ExitCode *int `json:"exit_code,omitempty"` + + // StderrB64 Base64-encoded stderr buffer. + StderrB64 *string `json:"stderr_b64,omitempty"` + + // StdoutB64 Base64-encoded stdout buffer. + StdoutB64 *string `json:"stdout_b64,omitempty"` +} + +// ProcessKillRequest Signal to send to the process. +type ProcessKillRequest struct { + // Signal Signal to send. + Signal ProcessKillRequestSignal `json:"signal"` +} + +// ProcessKillRequestSignal Signal to send. +type ProcessKillRequestSignal string + +// ProcessSpawnRequest defines model for ProcessSpawnRequest. +type ProcessSpawnRequest struct { + // Args Command arguments. + Args *[]string `json:"args,omitempty"` + + // AsRoot Run the process with root privileges. + AsRoot *bool `json:"as_root,omitempty"` + + // AsUser Run the process as this user. + AsUser *string `json:"as_user"` + + // Command Executable or shell command to run. + Command string `json:"command"` + + // Cwd Working directory (absolute path) to run the command in. + Cwd *string `json:"cwd"` + + // Env Environment variables to set for the process. + Env *map[string]string `json:"env,omitempty"` + + // Stream Streaming is handled via the stdout/stream endpoint for spawned processes. + Stream *bool `json:"stream,omitempty"` + + // TimeoutSec Maximum execution time in seconds. + TimeoutSec *int `json:"timeout_sec"` +} + +// ProcessSpawnResult Information about a spawned process. +type ProcessSpawnResult struct { + // Pid OS process ID. + Pid *int `json:"pid,omitempty"` + + // ProcessId Server-assigned identifier for the process. + ProcessId *openapi_types.UUID `json:"process_id,omitempty"` + + // StartedAt Timestamp when the process started. + StartedAt *time.Time `json:"started_at,omitempty"` +} + +// ProcessStatus Current status of a process. +type ProcessStatus struct { + // CpuPct Estimated CPU usage percentage. + CpuPct *float32 `json:"cpu_pct,omitempty"` + + // ExitCode Exit code if the process has exited. + ExitCode *int `json:"exit_code"` + + // MemBytes Estimated resident memory usage in bytes. + MemBytes *int `json:"mem_bytes,omitempty"` + + // State Process state. + State *ProcessStatusState `json:"state,omitempty"` +} + +// ProcessStatusState Process state. +type ProcessStatusState string + +// ProcessStdinRequest Data to write to the process standard input. +type ProcessStdinRequest struct { + // DataB64 Base64-encoded data to write. + DataB64 string `json:"data_b64"` +} + +// ProcessStdinResult Result of writing to stdin. +type ProcessStdinResult struct { + // WrittenBytes Number of bytes written. + WrittenBytes *int `json:"written_bytes,omitempty"` +} + +// ProcessStreamEvent SSE payload representing process output or lifecycle events. +type ProcessStreamEvent struct { + // DataB64 Base64-encoded data from the process stream. + DataB64 *string `json:"data_b64,omitempty"` + + // Event Lifecycle event type. + Event *ProcessStreamEventEvent `json:"event,omitempty"` + + // ExitCode Exit code when the event is "exit". + ExitCode *int `json:"exit_code,omitempty"` + + // Stream Source stream of the data chunk. + Stream *ProcessStreamEventStream `json:"stream,omitempty"` +} + +// ProcessStreamEventEvent Lifecycle event type. +type ProcessStreamEventEvent string + +// ProcessStreamEventStream Source stream of the data chunk. +type ProcessStreamEventStream string + // RecorderInfo defines model for RecorderInfo. type RecorderInfo struct { // FinishedAt Timestamp when recording finished @@ -279,6 +466,21 @@ type WriteFileParams struct { Mode *string `form:"mode,omitempty" json:"mode,omitempty"` } +// LogsStreamParams defines parameters for LogsStream. +type LogsStreamParams struct { + Source LogsStreamParamsSource `form:"source" json:"source"` + Follow *bool `form:"follow,omitempty" json:"follow,omitempty"` + + // Path only required if source is path + Path *string `form:"path,omitempty" json:"path,omitempty"` + + // SupervisorProcess only required if source is supervisor + SupervisorProcess *string `form:"supervisor_process,omitempty" json:"supervisor_process,omitempty"` +} + +// LogsStreamParamsSource defines parameters for LogsStream. +type LogsStreamParamsSource string + // DownloadRecordingParams defines parameters for DownloadRecording. type DownloadRecordingParams struct { // Id Optional recorder identifier. When omitted, the server uses the default recorder. @@ -315,6 +517,18 @@ type UploadZipMultipartRequestBody UploadZipMultipartBody // StartFsWatchJSONRequestBody defines body for StartFsWatch for application/json ContentType. type StartFsWatchJSONRequestBody = StartFsWatchRequest +// ProcessExecJSONRequestBody defines body for ProcessExec for application/json ContentType. +type ProcessExecJSONRequestBody = ProcessExecRequest + +// ProcessSpawnJSONRequestBody defines body for ProcessSpawn for application/json ContentType. +type ProcessSpawnJSONRequestBody = ProcessSpawnRequest + +// ProcessKillJSONRequestBody defines body for ProcessKill for application/json ContentType. +type ProcessKillJSONRequestBody = ProcessKillRequest + +// ProcessStdinJSONRequestBody defines body for ProcessStdin for application/json ContentType. +type ProcessStdinJSONRequestBody = ProcessStdinRequest + // DeleteRecordingJSONRequestBody defines body for DeleteRecording for application/json ContentType. type DeleteRecordingJSONRequestBody = DeleteRecordingRequest @@ -461,6 +675,35 @@ type ClientInterface interface { // WriteFileWithBody request with any body WriteFileWithBody(ctx context.Context, params *WriteFileParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + // LogsStream request + LogsStream(ctx context.Context, params *LogsStreamParams, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ProcessExecWithBody request with any body + ProcessExecWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ProcessExec(ctx context.Context, body ProcessExecJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ProcessSpawnWithBody request with any body + ProcessSpawnWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ProcessSpawn(ctx context.Context, body ProcessSpawnJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ProcessKillWithBody request with any body + ProcessKillWithBody(ctx context.Context, processId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ProcessKill(ctx context.Context, processId openapi_types.UUID, body ProcessKillJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ProcessStatus request + ProcessStatus(ctx context.Context, processId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ProcessStdinWithBody request with any body + ProcessStdinWithBody(ctx context.Context, processId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + ProcessStdin(ctx context.Context, processId openapi_types.UUID, body ProcessStdinJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // ProcessStdoutStream request + ProcessStdoutStream(ctx context.Context, processId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) + // DeleteRecordingWithBody request with any body DeleteRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -771,6 +1014,138 @@ func (c *Client) WriteFileWithBody(ctx context.Context, params *WriteFileParams, return c.Client.Do(req) } +func (c *Client) LogsStream(ctx context.Context, params *LogsStreamParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewLogsStreamRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ProcessExecWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProcessExecRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ProcessExec(ctx context.Context, body ProcessExecJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProcessExecRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ProcessSpawnWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProcessSpawnRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ProcessSpawn(ctx context.Context, body ProcessSpawnJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProcessSpawnRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ProcessKillWithBody(ctx context.Context, processId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProcessKillRequestWithBody(c.Server, processId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ProcessKill(ctx context.Context, processId openapi_types.UUID, body ProcessKillJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProcessKillRequest(c.Server, processId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ProcessStatus(ctx context.Context, processId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProcessStatusRequest(c.Server, processId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ProcessStdinWithBody(ctx context.Context, processId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProcessStdinRequestWithBody(c.Server, processId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ProcessStdin(ctx context.Context, processId openapi_types.UUID, body ProcessStdinJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProcessStdinRequest(c.Server, processId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) ProcessStdoutStream(ctx context.Context, processId openapi_types.UUID, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewProcessStdoutStreamRequest(c.Server, processId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) DeleteRecordingWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewDeleteRecordingRequestWithBody(c.Server, contentType, body) if err != nil { @@ -1511,48 +1886,8 @@ func NewWriteFileRequestWithBody(server string, params *WriteFileParams, content return req, nil } -// NewDeleteRecordingRequest calls the generic DeleteRecording builder with application/json body -func NewDeleteRecordingRequest(server string, body DeleteRecordingJSONRequestBody) (*http.Request, error) { - var bodyReader io.Reader - buf, err := json.Marshal(body) - if err != nil { - return nil, err - } - bodyReader = bytes.NewReader(buf) - return NewDeleteRecordingRequestWithBody(server, "application/json", bodyReader) -} - -// NewDeleteRecordingRequestWithBody generates requests for DeleteRecording with any type of body -func NewDeleteRecordingRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { - var err error - - serverURL, err := url.Parse(server) - if err != nil { - return nil, err - } - - operationPath := fmt.Sprintf("/recording/delete") - if operationPath[0] == '/' { - operationPath = "." + operationPath - } - - queryURL, err := serverURL.Parse(operationPath) - if err != nil { - return nil, err - } - - req, err := http.NewRequest("POST", queryURL.String(), body) - if err != nil { - return nil, err - } - - req.Header.Add("Content-Type", contentType) - - return req, nil -} - -// NewDownloadRecordingRequest generates requests for DownloadRecording -func NewDownloadRecordingRequest(server string, params *DownloadRecordingParams) (*http.Request, error) { +// NewLogsStreamRequest generates requests for LogsStream +func NewLogsStreamRequest(server string, params *LogsStreamParams) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -1560,7 +1895,7 @@ func NewDownloadRecordingRequest(server string, params *DownloadRecordingParams) return nil, err } - operationPath := fmt.Sprintf("/recording/download") + operationPath := fmt.Sprintf("/logs/stream") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -1573,9 +1908,21 @@ func NewDownloadRecordingRequest(server string, params *DownloadRecordingParams) if params != nil { queryValues := queryURL.Query() - if params.Id != nil { + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "source", runtime.ParamLocationQuery, params.Source); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } - if queryFrag, err := runtime.StyleParamWithLocation("form", true, "id", runtime.ParamLocationQuery, *params.Id); err != nil { + if params.Follow != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "follow", runtime.ParamLocationQuery, *params.Follow); err != nil { return nil, err } else if parsed, err := url.ParseQuery(queryFrag); err != nil { return nil, err @@ -1589,8 +1936,40 @@ func NewDownloadRecordingRequest(server string, params *DownloadRecordingParams) } - queryURL.RawQuery = queryValues.Encode() - } + if params.Path != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "path", runtime.ParamLocationQuery, *params.Path); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + if params.SupervisorProcess != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "supervisor_process", runtime.ParamLocationQuery, *params.SupervisorProcess); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } req, err := http.NewRequest("GET", queryURL.String(), nil) if err != nil { @@ -1600,8 +1979,19 @@ func NewDownloadRecordingRequest(server string, params *DownloadRecordingParams) return req, nil } -// NewListRecordersRequest generates requests for ListRecorders -func NewListRecordersRequest(server string) (*http.Request, error) { +// NewProcessExecRequest calls the generic ProcessExec builder with application/json body +func NewProcessExecRequest(server string, body ProcessExecJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewProcessExecRequestWithBody(server, "application/json", bodyReader) +} + +// NewProcessExecRequestWithBody generates requests for ProcessExec with any type of body +func NewProcessExecRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -1609,7 +1999,7 @@ func NewListRecordersRequest(server string) (*http.Request, error) { return nil, err } - operationPath := fmt.Sprintf("/recording/list") + operationPath := fmt.Sprintf("/process/exec") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -1619,27 +2009,29 @@ func NewListRecordersRequest(server string) (*http.Request, error) { return nil, err } - req, err := http.NewRequest("GET", queryURL.String(), nil) + req, err := http.NewRequest("POST", queryURL.String(), body) if err != nil { return nil, err } + req.Header.Add("Content-Type", contentType) + return req, nil } -// NewStartRecordingRequest calls the generic StartRecording builder with application/json body -func NewStartRecordingRequest(server string, body StartRecordingJSONRequestBody) (*http.Request, error) { +// NewProcessSpawnRequest calls the generic ProcessSpawn builder with application/json body +func NewProcessSpawnRequest(server string, body ProcessSpawnJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader buf, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(buf) - return NewStartRecordingRequestWithBody(server, "application/json", bodyReader) + return NewProcessSpawnRequestWithBody(server, "application/json", bodyReader) } -// NewStartRecordingRequestWithBody generates requests for StartRecording with any type of body -func NewStartRecordingRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { +// NewProcessSpawnRequestWithBody generates requests for ProcessSpawn with any type of body +func NewProcessSpawnRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { var err error serverURL, err := url.Parse(server) @@ -1647,7 +2039,7 @@ func NewStartRecordingRequestWithBody(server string, contentType string, body io return nil, err } - operationPath := fmt.Sprintf("/recording/start") + operationPath := fmt.Sprintf("/process/spawn") if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -1667,27 +2059,34 @@ func NewStartRecordingRequestWithBody(server string, contentType string, body io return req, nil } -// NewStopRecordingRequest calls the generic StopRecording builder with application/json body -func NewStopRecordingRequest(server string, body StopRecordingJSONRequestBody) (*http.Request, error) { +// NewProcessKillRequest calls the generic ProcessKill builder with application/json body +func NewProcessKillRequest(server string, processId openapi_types.UUID, body ProcessKillJSONRequestBody) (*http.Request, error) { var bodyReader io.Reader buf, err := json.Marshal(body) if err != nil { return nil, err } bodyReader = bytes.NewReader(buf) - return NewStopRecordingRequestWithBody(server, "application/json", bodyReader) + return NewProcessKillRequestWithBody(server, processId, "application/json", bodyReader) } -// NewStopRecordingRequestWithBody generates requests for StopRecording with any type of body -func NewStopRecordingRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { +// NewProcessKillRequestWithBody generates requests for ProcessKill with any type of body +func NewProcessKillRequestWithBody(server string, processId openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { var err error + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "process_id", runtime.ParamLocationPath, processId) + if err != nil { + return nil, err + } + serverURL, err := url.Parse(server) if err != nil { return nil, err } - operationPath := fmt.Sprintf("/recording/stop") + operationPath := fmt.Sprintf("/process/%s/kill", pathParam0) if operationPath[0] == '/' { operationPath = "." + operationPath } @@ -1707,125 +2106,465 @@ func NewStopRecordingRequestWithBody(server string, contentType string, body io. return req, nil } -func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { - for _, r := range c.RequestEditors { - if err := r(ctx, req); err != nil { - return err - } +// NewProcessStatusRequest generates requests for ProcessStatus +func NewProcessStatusRequest(server string, processId openapi_types.UUID) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "process_id", runtime.ParamLocationPath, processId) + if err != nil { + return nil, err } - for _, r := range additionalEditors { - if err := r(ctx, req); err != nil { - return err - } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err } - return nil -} -// ClientWithResponses builds on ClientInterface to offer response payloads -type ClientWithResponses struct { - ClientInterface -} + operationPath := fmt.Sprintf("/process/%s/status", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } -// NewClientWithResponses creates a new ClientWithResponses, which wraps -// Client with return type handling -func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { - client, err := NewClient(server, opts...) + queryURL, err := serverURL.Parse(operationPath) if err != nil { return nil, err } - return &ClientWithResponses{client}, nil + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil } -// WithBaseURL overrides the baseURL. -func WithBaseURL(baseURL string) ClientOption { - return func(c *Client) error { - newBaseURL, err := url.Parse(baseURL) - if err != nil { - return err - } - c.Server = newBaseURL.String() - return nil +// NewProcessStdinRequest calls the generic ProcessStdin builder with application/json body +func NewProcessStdinRequest(server string, processId openapi_types.UUID, body ProcessStdinJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err } + bodyReader = bytes.NewReader(buf) + return NewProcessStdinRequestWithBody(server, processId, "application/json", bodyReader) } -// ClientWithResponsesInterface is the interface specification for the client with responses above. -type ClientWithResponsesInterface interface { - // ClickMouseWithBodyWithResponse request with any body - ClickMouseWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClickMouseResponse, error) +// NewProcessStdinRequestWithBody generates requests for ProcessStdin with any type of body +func NewProcessStdinRequestWithBody(server string, processId openapi_types.UUID, contentType string, body io.Reader) (*http.Request, error) { + var err error - ClickMouseWithResponse(ctx context.Context, body ClickMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*ClickMouseResponse, error) + var pathParam0 string - // MoveMouseWithBodyWithResponse request with any body - MoveMouseWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "process_id", runtime.ParamLocationPath, processId) + if err != nil { + return nil, err + } - MoveMouseWithResponse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } - // CreateDirectoryWithBodyWithResponse request with any body - CreateDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) + operationPath := fmt.Sprintf("/process/%s/stdin", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } - CreateDirectoryWithResponse(ctx context.Context, body CreateDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } - // DeleteDirectoryWithBodyWithResponse request with any body - DeleteDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteDirectoryResponse, error) + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } - DeleteDirectoryWithResponse(ctx context.Context, body DeleteDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteDirectoryResponse, error) + req.Header.Add("Content-Type", contentType) - // DeleteFileWithBodyWithResponse request with any body - DeleteFileWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteFileResponse, error) + return req, nil +} - DeleteFileWithResponse(ctx context.Context, body DeleteFileJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteFileResponse, error) +// NewProcessStdoutStreamRequest generates requests for ProcessStdoutStream +func NewProcessStdoutStreamRequest(server string, processId openapi_types.UUID) (*http.Request, error) { + var err error - // FileInfoWithResponse request - FileInfoWithResponse(ctx context.Context, params *FileInfoParams, reqEditors ...RequestEditorFn) (*FileInfoResponse, error) + var pathParam0 string - // ListFilesWithResponse request - ListFilesWithResponse(ctx context.Context, params *ListFilesParams, reqEditors ...RequestEditorFn) (*ListFilesResponse, error) + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "process_id", runtime.ParamLocationPath, processId) + if err != nil { + return nil, err + } - // MovePathWithBodyWithResponse request with any body - MovePathWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MovePathResponse, error) + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } - MovePathWithResponse(ctx context.Context, body MovePathJSONRequestBody, reqEditors ...RequestEditorFn) (*MovePathResponse, error) + operationPath := fmt.Sprintf("/process/%s/stdout/stream", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } - // ReadFileWithResponse request - ReadFileWithResponse(ctx context.Context, params *ReadFileParams, reqEditors ...RequestEditorFn) (*ReadFileResponse, error) + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } - // SetFilePermissionsWithBodyWithResponse request with any body - SetFilePermissionsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetFilePermissionsResponse, error) + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } - SetFilePermissionsWithResponse(ctx context.Context, body SetFilePermissionsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetFilePermissionsResponse, error) + return req, nil +} - // UploadFilesWithBodyWithResponse request with any body - UploadFilesWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadFilesResponse, error) +// NewDeleteRecordingRequest calls the generic DeleteRecording builder with application/json body +func NewDeleteRecordingRequest(server string, body DeleteRecordingJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewDeleteRecordingRequestWithBody(server, "application/json", bodyReader) +} - // UploadZipWithBodyWithResponse request with any body - UploadZipWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadZipResponse, error) +// NewDeleteRecordingRequestWithBody generates requests for DeleteRecording with any type of body +func NewDeleteRecordingRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error - // StartFsWatchWithBodyWithResponse request with any body - StartFsWatchWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartFsWatchResponse, error) + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } - StartFsWatchWithResponse(ctx context.Context, body StartFsWatchJSONRequestBody, reqEditors ...RequestEditorFn) (*StartFsWatchResponse, error) + operationPath := fmt.Sprintf("/recording/delete") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } - // StopFsWatchWithResponse request - StopFsWatchWithResponse(ctx context.Context, watchId string, reqEditors ...RequestEditorFn) (*StopFsWatchResponse, error) + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } - // StreamFsEventsWithResponse request - StreamFsEventsWithResponse(ctx context.Context, watchId string, reqEditors ...RequestEditorFn) (*StreamFsEventsResponse, error) + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } - // WriteFileWithBodyWithResponse request with any body - WriteFileWithBodyWithResponse(ctx context.Context, params *WriteFileParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*WriteFileResponse, error) + req.Header.Add("Content-Type", contentType) - // DeleteRecordingWithBodyWithResponse request with any body - DeleteRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) + return req, nil +} - DeleteRecordingWithResponse(ctx context.Context, body DeleteRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) +// NewDownloadRecordingRequest generates requests for DownloadRecording +func NewDownloadRecordingRequest(server string, params *DownloadRecordingParams) (*http.Request, error) { + var err error - // DownloadRecordingWithResponse request - DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } - // ListRecordersWithResponse request - ListRecordersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRecordersResponse, error) + operationPath := fmt.Sprintf("/recording/download") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } - // StartRecordingWithBodyWithResponse request with any body + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if params.Id != nil { + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "id", runtime.ParamLocationQuery, *params.Id); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewListRecordersRequest generates requests for ListRecorders +func NewListRecordersRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/recording/list") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewStartRecordingRequest calls the generic StartRecording builder with application/json body +func NewStartRecordingRequest(server string, body StartRecordingJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewStartRecordingRequestWithBody(server, "application/json", bodyReader) +} + +// NewStartRecordingRequestWithBody generates requests for StartRecording with any type of body +func NewStartRecordingRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/recording/start") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewStopRecordingRequest calls the generic StopRecording builder with application/json body +func NewStopRecordingRequest(server string, body StopRecordingJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewStopRecordingRequestWithBody(server, "application/json", bodyReader) +} + +// NewStopRecordingRequestWithBody generates requests for StopRecording with any type of body +func NewStopRecordingRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/recording/stop") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // ClickMouseWithBodyWithResponse request with any body + ClickMouseWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClickMouseResponse, error) + + ClickMouseWithResponse(ctx context.Context, body ClickMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*ClickMouseResponse, error) + + // MoveMouseWithBodyWithResponse request with any body + MoveMouseWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) + + MoveMouseWithResponse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) + + // CreateDirectoryWithBodyWithResponse request with any body + CreateDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) + + CreateDirectoryWithResponse(ctx context.Context, body CreateDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) + + // DeleteDirectoryWithBodyWithResponse request with any body + DeleteDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteDirectoryResponse, error) + + DeleteDirectoryWithResponse(ctx context.Context, body DeleteDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteDirectoryResponse, error) + + // DeleteFileWithBodyWithResponse request with any body + DeleteFileWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteFileResponse, error) + + DeleteFileWithResponse(ctx context.Context, body DeleteFileJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteFileResponse, error) + + // FileInfoWithResponse request + FileInfoWithResponse(ctx context.Context, params *FileInfoParams, reqEditors ...RequestEditorFn) (*FileInfoResponse, error) + + // ListFilesWithResponse request + ListFilesWithResponse(ctx context.Context, params *ListFilesParams, reqEditors ...RequestEditorFn) (*ListFilesResponse, error) + + // MovePathWithBodyWithResponse request with any body + MovePathWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MovePathResponse, error) + + MovePathWithResponse(ctx context.Context, body MovePathJSONRequestBody, reqEditors ...RequestEditorFn) (*MovePathResponse, error) + + // ReadFileWithResponse request + ReadFileWithResponse(ctx context.Context, params *ReadFileParams, reqEditors ...RequestEditorFn) (*ReadFileResponse, error) + + // SetFilePermissionsWithBodyWithResponse request with any body + SetFilePermissionsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetFilePermissionsResponse, error) + + SetFilePermissionsWithResponse(ctx context.Context, body SetFilePermissionsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetFilePermissionsResponse, error) + + // UploadFilesWithBodyWithResponse request with any body + UploadFilesWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadFilesResponse, error) + + // UploadZipWithBodyWithResponse request with any body + UploadZipWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadZipResponse, error) + + // StartFsWatchWithBodyWithResponse request with any body + StartFsWatchWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartFsWatchResponse, error) + + StartFsWatchWithResponse(ctx context.Context, body StartFsWatchJSONRequestBody, reqEditors ...RequestEditorFn) (*StartFsWatchResponse, error) + + // StopFsWatchWithResponse request + StopFsWatchWithResponse(ctx context.Context, watchId string, reqEditors ...RequestEditorFn) (*StopFsWatchResponse, error) + + // StreamFsEventsWithResponse request + StreamFsEventsWithResponse(ctx context.Context, watchId string, reqEditors ...RequestEditorFn) (*StreamFsEventsResponse, error) + + // WriteFileWithBodyWithResponse request with any body + WriteFileWithBodyWithResponse(ctx context.Context, params *WriteFileParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*WriteFileResponse, error) + + // LogsStreamWithResponse request + LogsStreamWithResponse(ctx context.Context, params *LogsStreamParams, reqEditors ...RequestEditorFn) (*LogsStreamResponse, error) + + // ProcessExecWithBodyWithResponse request with any body + ProcessExecWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ProcessExecResponse, error) + + ProcessExecWithResponse(ctx context.Context, body ProcessExecJSONRequestBody, reqEditors ...RequestEditorFn) (*ProcessExecResponse, error) + + // ProcessSpawnWithBodyWithResponse request with any body + ProcessSpawnWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ProcessSpawnResponse, error) + + ProcessSpawnWithResponse(ctx context.Context, body ProcessSpawnJSONRequestBody, reqEditors ...RequestEditorFn) (*ProcessSpawnResponse, error) + + // ProcessKillWithBodyWithResponse request with any body + ProcessKillWithBodyWithResponse(ctx context.Context, processId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ProcessKillResponse, error) + + ProcessKillWithResponse(ctx context.Context, processId openapi_types.UUID, body ProcessKillJSONRequestBody, reqEditors ...RequestEditorFn) (*ProcessKillResponse, error) + + // ProcessStatusWithResponse request + ProcessStatusWithResponse(ctx context.Context, processId openapi_types.UUID, reqEditors ...RequestEditorFn) (*ProcessStatusResponse, error) + + // ProcessStdinWithBodyWithResponse request with any body + ProcessStdinWithBodyWithResponse(ctx context.Context, processId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ProcessStdinResponse, error) + + ProcessStdinWithResponse(ctx context.Context, processId openapi_types.UUID, body ProcessStdinJSONRequestBody, reqEditors ...RequestEditorFn) (*ProcessStdinResponse, error) + + // ProcessStdoutStreamWithResponse request + ProcessStdoutStreamWithResponse(ctx context.Context, processId openapi_types.UUID, reqEditors ...RequestEditorFn) (*ProcessStdoutStreamResponse, error) + + // DeleteRecordingWithBodyWithResponse request with any body + DeleteRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) + + DeleteRecordingWithResponse(ctx context.Context, body DeleteRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) + + // DownloadRecordingWithResponse request + DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) + + // ListRecordersWithResponse request + ListRecordersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRecordersResponse, error) + + // StartRecordingWithBodyWithResponse request with any body StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) StartRecordingWithResponse(ctx context.Context, body StartRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) @@ -1988,7 +2727,179 @@ type ListFilesResponse struct { } // Status returns HTTPResponse.Status -func (r ListFilesResponse) Status() string { +func (r ListFilesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ListFilesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type MovePathResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r MovePathResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r MovePathResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type ReadFileResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r ReadFileResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r ReadFileResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type SetFilePermissionsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r SetFilePermissionsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r SetFilePermissionsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type UploadFilesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r UploadFilesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UploadFilesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type UploadZipResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r UploadZipResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r UploadZipResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type StartFsWatchResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *struct { + // WatchId Unique identifier for the directory watch + WatchId *string `json:"watch_id,omitempty"` + } + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r StartFsWatchResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r StartFsWatchResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type StopFsWatchResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r StopFsWatchResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -1996,14 +2907,14 @@ func (r ListFilesResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r ListFilesResponse) StatusCode() int { +func (r StopFsWatchResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type MovePathResponse struct { +type StreamFsEventsResponse struct { Body []byte HTTPResponse *http.Response JSON400 *BadRequestError @@ -2012,7 +2923,7 @@ type MovePathResponse struct { } // Status returns HTTPResponse.Status -func (r MovePathResponse) Status() string { +func (r StreamFsEventsResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -2020,14 +2931,14 @@ func (r MovePathResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r MovePathResponse) StatusCode() int { +func (r StreamFsEventsResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type ReadFileResponse struct { +type WriteFileResponse struct { Body []byte HTTPResponse *http.Response JSON400 *BadRequestError @@ -2036,7 +2947,7 @@ type ReadFileResponse struct { } // Status returns HTTPResponse.Status -func (r ReadFileResponse) Status() string { +func (r WriteFileResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -2044,23 +2955,20 @@ func (r ReadFileResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r ReadFileResponse) StatusCode() int { +func (r WriteFileResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type SetFilePermissionsResponse struct { +type LogsStreamResponse struct { Body []byte HTTPResponse *http.Response - JSON400 *BadRequestError - JSON404 *NotFoundError - JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r SetFilePermissionsResponse) Status() string { +func (r LogsStreamResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -2068,23 +2976,23 @@ func (r SetFilePermissionsResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r SetFilePermissionsResponse) StatusCode() int { +func (r LogsStreamResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type UploadFilesResponse struct { +type ProcessExecResponse struct { Body []byte HTTPResponse *http.Response + JSON200 *ProcessExecResult JSON400 *BadRequestError - JSON404 *NotFoundError JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r UploadFilesResponse) Status() string { +func (r ProcessExecResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -2092,23 +3000,23 @@ func (r UploadFilesResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r UploadFilesResponse) StatusCode() int { +func (r ProcessExecResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type UploadZipResponse struct { +type ProcessSpawnResponse struct { Body []byte HTTPResponse *http.Response + JSON200 *ProcessSpawnResult JSON400 *BadRequestError - JSON404 *NotFoundError JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r UploadZipResponse) Status() string { +func (r ProcessSpawnResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -2116,27 +3024,24 @@ func (r UploadZipResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r UploadZipResponse) StatusCode() int { +func (r ProcessSpawnResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type StartFsWatchResponse struct { +type ProcessKillResponse struct { Body []byte HTTPResponse *http.Response - JSON201 *struct { - // WatchId Unique identifier for the directory watch - WatchId *string `json:"watch_id,omitempty"` - } - JSON400 *BadRequestError - JSON404 *NotFoundError - JSON500 *InternalError + JSON200 *OkResponse + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r StartFsWatchResponse) Status() string { +func (r ProcessKillResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -2144,23 +3049,24 @@ func (r StartFsWatchResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r StartFsWatchResponse) StatusCode() int { +func (r ProcessKillResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type StopFsWatchResponse struct { +type ProcessStatusResponse struct { Body []byte HTTPResponse *http.Response + JSON200 *ProcessStatus JSON400 *BadRequestError JSON404 *NotFoundError JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r StopFsWatchResponse) Status() string { +func (r ProcessStatusResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -2168,23 +3074,24 @@ func (r StopFsWatchResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r StopFsWatchResponse) StatusCode() int { +func (r ProcessStatusResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type StreamFsEventsResponse struct { +type ProcessStdinResponse struct { Body []byte HTTPResponse *http.Response + JSON200 *ProcessStdinResult JSON400 *BadRequestError JSON404 *NotFoundError JSON500 *InternalError } // Status returns HTTPResponse.Status -func (r StreamFsEventsResponse) Status() string { +func (r ProcessStdinResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -2192,14 +3099,14 @@ func (r StreamFsEventsResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r StreamFsEventsResponse) StatusCode() int { +func (r ProcessStdinResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } return 0 } -type WriteFileResponse struct { +type ProcessStdoutStreamResponse struct { Body []byte HTTPResponse *http.Response JSON400 *BadRequestError @@ -2208,7 +3115,7 @@ type WriteFileResponse struct { } // Status returns HTTPResponse.Status -func (r WriteFileResponse) Status() string { +func (r ProcessStdoutStreamResponse) Status() string { if r.HTTPResponse != nil { return r.HTTPResponse.Status } @@ -2216,7 +3123,7 @@ func (r WriteFileResponse) Status() string { } // StatusCode returns HTTPResponse.StatusCode -func (r WriteFileResponse) StatusCode() int { +func (r ProcessStdoutStreamResponse) StatusCode() int { if r.HTTPResponse != nil { return r.HTTPResponse.StatusCode } @@ -2318,320 +3225,648 @@ func (r StartRecordingResponse) StatusCode() int { return 0 } -type StopRecordingResponse struct { - Body []byte - HTTPResponse *http.Response - JSON400 *BadRequestError - JSON500 *InternalError +type StopRecordingResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r StopRecordingResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r StopRecordingResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// ClickMouseWithBodyWithResponse request with arbitrary body returning *ClickMouseResponse +func (c *ClientWithResponses) ClickMouseWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClickMouseResponse, error) { + rsp, err := c.ClickMouseWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseClickMouseResponse(rsp) +} + +func (c *ClientWithResponses) ClickMouseWithResponse(ctx context.Context, body ClickMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*ClickMouseResponse, error) { + rsp, err := c.ClickMouse(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseClickMouseResponse(rsp) +} + +// MoveMouseWithBodyWithResponse request with arbitrary body returning *MoveMouseResponse +func (c *ClientWithResponses) MoveMouseWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) { + rsp, err := c.MoveMouseWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseMoveMouseResponse(rsp) +} + +func (c *ClientWithResponses) MoveMouseWithResponse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) { + rsp, err := c.MoveMouse(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseMoveMouseResponse(rsp) +} + +// CreateDirectoryWithBodyWithResponse request with arbitrary body returning *CreateDirectoryResponse +func (c *ClientWithResponses) CreateDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) { + rsp, err := c.CreateDirectoryWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateDirectoryResponse(rsp) +} + +func (c *ClientWithResponses) CreateDirectoryWithResponse(ctx context.Context, body CreateDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) { + rsp, err := c.CreateDirectory(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseCreateDirectoryResponse(rsp) +} + +// DeleteDirectoryWithBodyWithResponse request with arbitrary body returning *DeleteDirectoryResponse +func (c *ClientWithResponses) DeleteDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteDirectoryResponse, error) { + rsp, err := c.DeleteDirectoryWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteDirectoryResponse(rsp) +} + +func (c *ClientWithResponses) DeleteDirectoryWithResponse(ctx context.Context, body DeleteDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteDirectoryResponse, error) { + rsp, err := c.DeleteDirectory(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteDirectoryResponse(rsp) +} + +// DeleteFileWithBodyWithResponse request with arbitrary body returning *DeleteFileResponse +func (c *ClientWithResponses) DeleteFileWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteFileResponse, error) { + rsp, err := c.DeleteFileWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteFileResponse(rsp) +} + +func (c *ClientWithResponses) DeleteFileWithResponse(ctx context.Context, body DeleteFileJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteFileResponse, error) { + rsp, err := c.DeleteFile(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteFileResponse(rsp) +} + +// FileInfoWithResponse request returning *FileInfoResponse +func (c *ClientWithResponses) FileInfoWithResponse(ctx context.Context, params *FileInfoParams, reqEditors ...RequestEditorFn) (*FileInfoResponse, error) { + rsp, err := c.FileInfo(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseFileInfoResponse(rsp) +} + +// ListFilesWithResponse request returning *ListFilesResponse +func (c *ClientWithResponses) ListFilesWithResponse(ctx context.Context, params *ListFilesParams, reqEditors ...RequestEditorFn) (*ListFilesResponse, error) { + rsp, err := c.ListFiles(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseListFilesResponse(rsp) +} + +// MovePathWithBodyWithResponse request with arbitrary body returning *MovePathResponse +func (c *ClientWithResponses) MovePathWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MovePathResponse, error) { + rsp, err := c.MovePathWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseMovePathResponse(rsp) +} + +func (c *ClientWithResponses) MovePathWithResponse(ctx context.Context, body MovePathJSONRequestBody, reqEditors ...RequestEditorFn) (*MovePathResponse, error) { + rsp, err := c.MovePath(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseMovePathResponse(rsp) +} + +// ReadFileWithResponse request returning *ReadFileResponse +func (c *ClientWithResponses) ReadFileWithResponse(ctx context.Context, params *ReadFileParams, reqEditors ...RequestEditorFn) (*ReadFileResponse, error) { + rsp, err := c.ReadFile(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseReadFileResponse(rsp) +} + +// SetFilePermissionsWithBodyWithResponse request with arbitrary body returning *SetFilePermissionsResponse +func (c *ClientWithResponses) SetFilePermissionsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetFilePermissionsResponse, error) { + rsp, err := c.SetFilePermissionsWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetFilePermissionsResponse(rsp) +} + +func (c *ClientWithResponses) SetFilePermissionsWithResponse(ctx context.Context, body SetFilePermissionsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetFilePermissionsResponse, error) { + rsp, err := c.SetFilePermissions(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseSetFilePermissionsResponse(rsp) +} + +// UploadFilesWithBodyWithResponse request with arbitrary body returning *UploadFilesResponse +func (c *ClientWithResponses) UploadFilesWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadFilesResponse, error) { + rsp, err := c.UploadFilesWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUploadFilesResponse(rsp) +} + +// UploadZipWithBodyWithResponse request with arbitrary body returning *UploadZipResponse +func (c *ClientWithResponses) UploadZipWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadZipResponse, error) { + rsp, err := c.UploadZipWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseUploadZipResponse(rsp) +} + +// StartFsWatchWithBodyWithResponse request with arbitrary body returning *StartFsWatchResponse +func (c *ClientWithResponses) StartFsWatchWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartFsWatchResponse, error) { + rsp, err := c.StartFsWatchWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseStartFsWatchResponse(rsp) +} + +func (c *ClientWithResponses) StartFsWatchWithResponse(ctx context.Context, body StartFsWatchJSONRequestBody, reqEditors ...RequestEditorFn) (*StartFsWatchResponse, error) { + rsp, err := c.StartFsWatch(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseStartFsWatchResponse(rsp) +} + +// StopFsWatchWithResponse request returning *StopFsWatchResponse +func (c *ClientWithResponses) StopFsWatchWithResponse(ctx context.Context, watchId string, reqEditors ...RequestEditorFn) (*StopFsWatchResponse, error) { + rsp, err := c.StopFsWatch(ctx, watchId, reqEditors...) + if err != nil { + return nil, err + } + return ParseStopFsWatchResponse(rsp) } -// Status returns HTTPResponse.Status -func (r StopRecordingResponse) Status() string { - if r.HTTPResponse != nil { - return r.HTTPResponse.Status +// StreamFsEventsWithResponse request returning *StreamFsEventsResponse +func (c *ClientWithResponses) StreamFsEventsWithResponse(ctx context.Context, watchId string, reqEditors ...RequestEditorFn) (*StreamFsEventsResponse, error) { + rsp, err := c.StreamFsEvents(ctx, watchId, reqEditors...) + if err != nil { + return nil, err } - return http.StatusText(0) + return ParseStreamFsEventsResponse(rsp) } -// StatusCode returns HTTPResponse.StatusCode -func (r StopRecordingResponse) StatusCode() int { - if r.HTTPResponse != nil { - return r.HTTPResponse.StatusCode +// WriteFileWithBodyWithResponse request with arbitrary body returning *WriteFileResponse +func (c *ClientWithResponses) WriteFileWithBodyWithResponse(ctx context.Context, params *WriteFileParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*WriteFileResponse, error) { + rsp, err := c.WriteFileWithBody(ctx, params, contentType, body, reqEditors...) + if err != nil { + return nil, err } - return 0 + return ParseWriteFileResponse(rsp) } -// ClickMouseWithBodyWithResponse request with arbitrary body returning *ClickMouseResponse -func (c *ClientWithResponses) ClickMouseWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ClickMouseResponse, error) { - rsp, err := c.ClickMouseWithBody(ctx, contentType, body, reqEditors...) +// LogsStreamWithResponse request returning *LogsStreamResponse +func (c *ClientWithResponses) LogsStreamWithResponse(ctx context.Context, params *LogsStreamParams, reqEditors ...RequestEditorFn) (*LogsStreamResponse, error) { + rsp, err := c.LogsStream(ctx, params, reqEditors...) if err != nil { return nil, err } - return ParseClickMouseResponse(rsp) + return ParseLogsStreamResponse(rsp) } -func (c *ClientWithResponses) ClickMouseWithResponse(ctx context.Context, body ClickMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*ClickMouseResponse, error) { - rsp, err := c.ClickMouse(ctx, body, reqEditors...) +// ProcessExecWithBodyWithResponse request with arbitrary body returning *ProcessExecResponse +func (c *ClientWithResponses) ProcessExecWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ProcessExecResponse, error) { + rsp, err := c.ProcessExecWithBody(ctx, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseClickMouseResponse(rsp) + return ParseProcessExecResponse(rsp) } -// MoveMouseWithBodyWithResponse request with arbitrary body returning *MoveMouseResponse -func (c *ClientWithResponses) MoveMouseWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) { - rsp, err := c.MoveMouseWithBody(ctx, contentType, body, reqEditors...) +func (c *ClientWithResponses) ProcessExecWithResponse(ctx context.Context, body ProcessExecJSONRequestBody, reqEditors ...RequestEditorFn) (*ProcessExecResponse, error) { + rsp, err := c.ProcessExec(ctx, body, reqEditors...) if err != nil { return nil, err } - return ParseMoveMouseResponse(rsp) + return ParseProcessExecResponse(rsp) } -func (c *ClientWithResponses) MoveMouseWithResponse(ctx context.Context, body MoveMouseJSONRequestBody, reqEditors ...RequestEditorFn) (*MoveMouseResponse, error) { - rsp, err := c.MoveMouse(ctx, body, reqEditors...) +// ProcessSpawnWithBodyWithResponse request with arbitrary body returning *ProcessSpawnResponse +func (c *ClientWithResponses) ProcessSpawnWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ProcessSpawnResponse, error) { + rsp, err := c.ProcessSpawnWithBody(ctx, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseMoveMouseResponse(rsp) + return ParseProcessSpawnResponse(rsp) } -// CreateDirectoryWithBodyWithResponse request with arbitrary body returning *CreateDirectoryResponse -func (c *ClientWithResponses) CreateDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) { - rsp, err := c.CreateDirectoryWithBody(ctx, contentType, body, reqEditors...) +func (c *ClientWithResponses) ProcessSpawnWithResponse(ctx context.Context, body ProcessSpawnJSONRequestBody, reqEditors ...RequestEditorFn) (*ProcessSpawnResponse, error) { + rsp, err := c.ProcessSpawn(ctx, body, reqEditors...) if err != nil { return nil, err } - return ParseCreateDirectoryResponse(rsp) + return ParseProcessSpawnResponse(rsp) } -func (c *ClientWithResponses) CreateDirectoryWithResponse(ctx context.Context, body CreateDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*CreateDirectoryResponse, error) { - rsp, err := c.CreateDirectory(ctx, body, reqEditors...) +// ProcessKillWithBodyWithResponse request with arbitrary body returning *ProcessKillResponse +func (c *ClientWithResponses) ProcessKillWithBodyWithResponse(ctx context.Context, processId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ProcessKillResponse, error) { + rsp, err := c.ProcessKillWithBody(ctx, processId, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseCreateDirectoryResponse(rsp) + return ParseProcessKillResponse(rsp) } -// DeleteDirectoryWithBodyWithResponse request with arbitrary body returning *DeleteDirectoryResponse -func (c *ClientWithResponses) DeleteDirectoryWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteDirectoryResponse, error) { - rsp, err := c.DeleteDirectoryWithBody(ctx, contentType, body, reqEditors...) +func (c *ClientWithResponses) ProcessKillWithResponse(ctx context.Context, processId openapi_types.UUID, body ProcessKillJSONRequestBody, reqEditors ...RequestEditorFn) (*ProcessKillResponse, error) { + rsp, err := c.ProcessKill(ctx, processId, body, reqEditors...) if err != nil { return nil, err } - return ParseDeleteDirectoryResponse(rsp) + return ParseProcessKillResponse(rsp) } -func (c *ClientWithResponses) DeleteDirectoryWithResponse(ctx context.Context, body DeleteDirectoryJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteDirectoryResponse, error) { - rsp, err := c.DeleteDirectory(ctx, body, reqEditors...) +// ProcessStatusWithResponse request returning *ProcessStatusResponse +func (c *ClientWithResponses) ProcessStatusWithResponse(ctx context.Context, processId openapi_types.UUID, reqEditors ...RequestEditorFn) (*ProcessStatusResponse, error) { + rsp, err := c.ProcessStatus(ctx, processId, reqEditors...) if err != nil { return nil, err } - return ParseDeleteDirectoryResponse(rsp) + return ParseProcessStatusResponse(rsp) } -// DeleteFileWithBodyWithResponse request with arbitrary body returning *DeleteFileResponse -func (c *ClientWithResponses) DeleteFileWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteFileResponse, error) { - rsp, err := c.DeleteFileWithBody(ctx, contentType, body, reqEditors...) +// ProcessStdinWithBodyWithResponse request with arbitrary body returning *ProcessStdinResponse +func (c *ClientWithResponses) ProcessStdinWithBodyWithResponse(ctx context.Context, processId openapi_types.UUID, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*ProcessStdinResponse, error) { + rsp, err := c.ProcessStdinWithBody(ctx, processId, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseDeleteFileResponse(rsp) + return ParseProcessStdinResponse(rsp) } -func (c *ClientWithResponses) DeleteFileWithResponse(ctx context.Context, body DeleteFileJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteFileResponse, error) { - rsp, err := c.DeleteFile(ctx, body, reqEditors...) +func (c *ClientWithResponses) ProcessStdinWithResponse(ctx context.Context, processId openapi_types.UUID, body ProcessStdinJSONRequestBody, reqEditors ...RequestEditorFn) (*ProcessStdinResponse, error) { + rsp, err := c.ProcessStdin(ctx, processId, body, reqEditors...) if err != nil { return nil, err } - return ParseDeleteFileResponse(rsp) + return ParseProcessStdinResponse(rsp) } -// FileInfoWithResponse request returning *FileInfoResponse -func (c *ClientWithResponses) FileInfoWithResponse(ctx context.Context, params *FileInfoParams, reqEditors ...RequestEditorFn) (*FileInfoResponse, error) { - rsp, err := c.FileInfo(ctx, params, reqEditors...) +// ProcessStdoutStreamWithResponse request returning *ProcessStdoutStreamResponse +func (c *ClientWithResponses) ProcessStdoutStreamWithResponse(ctx context.Context, processId openapi_types.UUID, reqEditors ...RequestEditorFn) (*ProcessStdoutStreamResponse, error) { + rsp, err := c.ProcessStdoutStream(ctx, processId, reqEditors...) if err != nil { return nil, err } - return ParseFileInfoResponse(rsp) + return ParseProcessStdoutStreamResponse(rsp) } -// ListFilesWithResponse request returning *ListFilesResponse -func (c *ClientWithResponses) ListFilesWithResponse(ctx context.Context, params *ListFilesParams, reqEditors ...RequestEditorFn) (*ListFilesResponse, error) { - rsp, err := c.ListFiles(ctx, params, reqEditors...) +// DeleteRecordingWithBodyWithResponse request with arbitrary body returning *DeleteRecordingResponse +func (c *ClientWithResponses) DeleteRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) { + rsp, err := c.DeleteRecordingWithBody(ctx, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseListFilesResponse(rsp) + return ParseDeleteRecordingResponse(rsp) } -// MovePathWithBodyWithResponse request with arbitrary body returning *MovePathResponse -func (c *ClientWithResponses) MovePathWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*MovePathResponse, error) { - rsp, err := c.MovePathWithBody(ctx, contentType, body, reqEditors...) +func (c *ClientWithResponses) DeleteRecordingWithResponse(ctx context.Context, body DeleteRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) { + rsp, err := c.DeleteRecording(ctx, body, reqEditors...) if err != nil { return nil, err } - return ParseMovePathResponse(rsp) + return ParseDeleteRecordingResponse(rsp) } -func (c *ClientWithResponses) MovePathWithResponse(ctx context.Context, body MovePathJSONRequestBody, reqEditors ...RequestEditorFn) (*MovePathResponse, error) { - rsp, err := c.MovePath(ctx, body, reqEditors...) +// DownloadRecordingWithResponse request returning *DownloadRecordingResponse +func (c *ClientWithResponses) DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) { + rsp, err := c.DownloadRecording(ctx, params, reqEditors...) if err != nil { return nil, err } - return ParseMovePathResponse(rsp) + return ParseDownloadRecordingResponse(rsp) } -// ReadFileWithResponse request returning *ReadFileResponse -func (c *ClientWithResponses) ReadFileWithResponse(ctx context.Context, params *ReadFileParams, reqEditors ...RequestEditorFn) (*ReadFileResponse, error) { - rsp, err := c.ReadFile(ctx, params, reqEditors...) +// ListRecordersWithResponse request returning *ListRecordersResponse +func (c *ClientWithResponses) ListRecordersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRecordersResponse, error) { + rsp, err := c.ListRecorders(ctx, reqEditors...) if err != nil { return nil, err } - return ParseReadFileResponse(rsp) + return ParseListRecordersResponse(rsp) } -// SetFilePermissionsWithBodyWithResponse request with arbitrary body returning *SetFilePermissionsResponse -func (c *ClientWithResponses) SetFilePermissionsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*SetFilePermissionsResponse, error) { - rsp, err := c.SetFilePermissionsWithBody(ctx, contentType, body, reqEditors...) +// StartRecordingWithBodyWithResponse request with arbitrary body returning *StartRecordingResponse +func (c *ClientWithResponses) StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) { + rsp, err := c.StartRecordingWithBody(ctx, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseSetFilePermissionsResponse(rsp) + return ParseStartRecordingResponse(rsp) } -func (c *ClientWithResponses) SetFilePermissionsWithResponse(ctx context.Context, body SetFilePermissionsJSONRequestBody, reqEditors ...RequestEditorFn) (*SetFilePermissionsResponse, error) { - rsp, err := c.SetFilePermissions(ctx, body, reqEditors...) +func (c *ClientWithResponses) StartRecordingWithResponse(ctx context.Context, body StartRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) { + rsp, err := c.StartRecording(ctx, body, reqEditors...) if err != nil { return nil, err } - return ParseSetFilePermissionsResponse(rsp) + return ParseStartRecordingResponse(rsp) } -// UploadFilesWithBodyWithResponse request with arbitrary body returning *UploadFilesResponse -func (c *ClientWithResponses) UploadFilesWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadFilesResponse, error) { - rsp, err := c.UploadFilesWithBody(ctx, contentType, body, reqEditors...) +// StopRecordingWithBodyWithResponse request with arbitrary body returning *StopRecordingResponse +func (c *ClientWithResponses) StopRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) { + rsp, err := c.StopRecordingWithBody(ctx, contentType, body, reqEditors...) if err != nil { return nil, err } - return ParseUploadFilesResponse(rsp) + return ParseStopRecordingResponse(rsp) } -// UploadZipWithBodyWithResponse request with arbitrary body returning *UploadZipResponse -func (c *ClientWithResponses) UploadZipWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*UploadZipResponse, error) { - rsp, err := c.UploadZipWithBody(ctx, contentType, body, reqEditors...) +func (c *ClientWithResponses) StopRecordingWithResponse(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) { + rsp, err := c.StopRecording(ctx, body, reqEditors...) if err != nil { return nil, err } - return ParseUploadZipResponse(rsp) + return ParseStopRecordingResponse(rsp) } -// StartFsWatchWithBodyWithResponse request with arbitrary body returning *StartFsWatchResponse -func (c *ClientWithResponses) StartFsWatchWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartFsWatchResponse, error) { - rsp, err := c.StartFsWatchWithBody(ctx, contentType, body, reqEditors...) +// ParseClickMouseResponse parses an HTTP response from a ClickMouseWithResponse call +func ParseClickMouseResponse(rsp *http.Response) (*ClickMouseResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - return ParseStartFsWatchResponse(rsp) + + response := &ClickMouseResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil } -func (c *ClientWithResponses) StartFsWatchWithResponse(ctx context.Context, body StartFsWatchJSONRequestBody, reqEditors ...RequestEditorFn) (*StartFsWatchResponse, error) { - rsp, err := c.StartFsWatch(ctx, body, reqEditors...) +// ParseMoveMouseResponse parses an HTTP response from a MoveMouseWithResponse call +func ParseMoveMouseResponse(rsp *http.Response) (*MoveMouseResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - return ParseStartFsWatchResponse(rsp) + + response := &MoveMouseResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil } -// StopFsWatchWithResponse request returning *StopFsWatchResponse -func (c *ClientWithResponses) StopFsWatchWithResponse(ctx context.Context, watchId string, reqEditors ...RequestEditorFn) (*StopFsWatchResponse, error) { - rsp, err := c.StopFsWatch(ctx, watchId, reqEditors...) +// ParseCreateDirectoryResponse parses an HTTP response from a CreateDirectoryWithResponse call +func ParseCreateDirectoryResponse(rsp *http.Response) (*CreateDirectoryResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - return ParseStopFsWatchResponse(rsp) + + response := &CreateDirectoryResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil } -// StreamFsEventsWithResponse request returning *StreamFsEventsResponse -func (c *ClientWithResponses) StreamFsEventsWithResponse(ctx context.Context, watchId string, reqEditors ...RequestEditorFn) (*StreamFsEventsResponse, error) { - rsp, err := c.StreamFsEvents(ctx, watchId, reqEditors...) - if err != nil { - return nil, err +// ParseDeleteDirectoryResponse parses an HTTP response from a DeleteDirectoryWithResponse call +func ParseDeleteDirectoryResponse(rsp *http.Response) (*DeleteDirectoryResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteDirectoryResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + } - return ParseStreamFsEventsResponse(rsp) -} -// WriteFileWithBodyWithResponse request with arbitrary body returning *WriteFileResponse -func (c *ClientWithResponses) WriteFileWithBodyWithResponse(ctx context.Context, params *WriteFileParams, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*WriteFileResponse, error) { - rsp, err := c.WriteFileWithBody(ctx, params, contentType, body, reqEditors...) - if err != nil { - return nil, err - } - return ParseWriteFileResponse(rsp) + return response, nil } -// DeleteRecordingWithBodyWithResponse request with arbitrary body returning *DeleteRecordingResponse -func (c *ClientWithResponses) DeleteRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) { - rsp, err := c.DeleteRecordingWithBody(ctx, contentType, body, reqEditors...) +// ParseDeleteFileResponse parses an HTTP response from a DeleteFileWithResponse call +func ParseDeleteFileResponse(rsp *http.Response) (*DeleteFileResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - return ParseDeleteRecordingResponse(rsp) -} -func (c *ClientWithResponses) DeleteRecordingWithResponse(ctx context.Context, body DeleteRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteRecordingResponse, error) { - rsp, err := c.DeleteRecording(ctx, body, reqEditors...) - if err != nil { - return nil, err + response := &DeleteFileResponse{ + Body: bodyBytes, + HTTPResponse: rsp, } - return ParseDeleteRecordingResponse(rsp) -} -// DownloadRecordingWithResponse request returning *DownloadRecordingResponse -func (c *ClientWithResponses) DownloadRecordingWithResponse(ctx context.Context, params *DownloadRecordingParams, reqEditors ...RequestEditorFn) (*DownloadRecordingResponse, error) { - rsp, err := c.DownloadRecording(ctx, params, reqEditors...) - if err != nil { - return nil, err - } - return ParseDownloadRecordingResponse(rsp) -} + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest -// ListRecordersWithResponse request returning *ListRecordersResponse -func (c *ClientWithResponses) ListRecordersWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*ListRecordersResponse, error) { - rsp, err := c.ListRecorders(ctx, reqEditors...) - if err != nil { - return nil, err - } - return ParseListRecordersResponse(rsp) -} + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest -// StartRecordingWithBodyWithResponse request with arbitrary body returning *StartRecordingResponse -func (c *ClientWithResponses) StartRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) { - rsp, err := c.StartRecordingWithBody(ctx, contentType, body, reqEditors...) - if err != nil { - return nil, err } - return ParseStartRecordingResponse(rsp) + + return response, nil } -func (c *ClientWithResponses) StartRecordingWithResponse(ctx context.Context, body StartRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StartRecordingResponse, error) { - rsp, err := c.StartRecording(ctx, body, reqEditors...) +// ParseFileInfoResponse parses an HTTP response from a FileInfoWithResponse call +func ParseFileInfoResponse(rsp *http.Response) (*FileInfoResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - return ParseStartRecordingResponse(rsp) -} -// StopRecordingWithBodyWithResponse request with arbitrary body returning *StopRecordingResponse -func (c *ClientWithResponses) StopRecordingWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) { - rsp, err := c.StopRecordingWithBody(ctx, contentType, body, reqEditors...) - if err != nil { - return nil, err + response := &FileInfoResponse{ + Body: bodyBytes, + HTTPResponse: rsp, } - return ParseStopRecordingResponse(rsp) -} -func (c *ClientWithResponses) StopRecordingWithResponse(ctx context.Context, body StopRecordingJSONRequestBody, reqEditors ...RequestEditorFn) (*StopRecordingResponse, error) { - rsp, err := c.StopRecording(ctx, body, reqEditors...) - if err != nil { - return nil, err + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest FileInfo + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + } - return ParseStopRecordingResponse(rsp) + + return response, nil } -// ParseClickMouseResponse parses an HTTP response from a ClickMouseWithResponse call -func ParseClickMouseResponse(rsp *http.Response) (*ClickMouseResponse, error) { +// ParseListFilesResponse parses an HTTP response from a ListFilesWithResponse call +func ParseListFilesResponse(rsp *http.Response) (*ListFilesResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &ClickMouseResponse{ + response := &ListFilesResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ListFiles + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest BadRequestError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -2639,6 +3874,13 @@ func ParseClickMouseResponse(rsp *http.Response) (*ClickMouseResponse, error) { } response.JSON400 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest InternalError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -2651,15 +3893,15 @@ func ParseClickMouseResponse(rsp *http.Response) (*ClickMouseResponse, error) { return response, nil } -// ParseMoveMouseResponse parses an HTTP response from a MoveMouseWithResponse call -func ParseMoveMouseResponse(rsp *http.Response) (*MoveMouseResponse, error) { +// ParseMovePathResponse parses an HTTP response from a MovePathWithResponse call +func ParseMovePathResponse(rsp *http.Response) (*MovePathResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &MoveMouseResponse{ + response := &MovePathResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -2672,6 +3914,13 @@ func ParseMoveMouseResponse(rsp *http.Response) (*MoveMouseResponse, error) { } response.JSON400 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest InternalError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -2684,15 +3933,15 @@ func ParseMoveMouseResponse(rsp *http.Response) (*MoveMouseResponse, error) { return response, nil } -// ParseCreateDirectoryResponse parses an HTTP response from a CreateDirectoryWithResponse call -func ParseCreateDirectoryResponse(rsp *http.Response) (*CreateDirectoryResponse, error) { +// ParseReadFileResponse parses an HTTP response from a ReadFileWithResponse call +func ParseReadFileResponse(rsp *http.Response) (*ReadFileResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &CreateDirectoryResponse{ + response := &ReadFileResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -2705,6 +3954,13 @@ func ParseCreateDirectoryResponse(rsp *http.Response) (*CreateDirectoryResponse, } response.JSON400 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest InternalError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -2717,15 +3973,15 @@ func ParseCreateDirectoryResponse(rsp *http.Response) (*CreateDirectoryResponse, return response, nil } -// ParseDeleteDirectoryResponse parses an HTTP response from a DeleteDirectoryWithResponse call -func ParseDeleteDirectoryResponse(rsp *http.Response) (*DeleteDirectoryResponse, error) { +// ParseSetFilePermissionsResponse parses an HTTP response from a SetFilePermissionsWithResponse call +func ParseSetFilePermissionsResponse(rsp *http.Response) (*SetFilePermissionsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &DeleteDirectoryResponse{ + response := &SetFilePermissionsResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -2757,15 +4013,15 @@ func ParseDeleteDirectoryResponse(rsp *http.Response) (*DeleteDirectoryResponse, return response, nil } -// ParseDeleteFileResponse parses an HTTP response from a DeleteFileWithResponse call -func ParseDeleteFileResponse(rsp *http.Response) (*DeleteFileResponse, error) { +// ParseUploadFilesResponse parses an HTTP response from a UploadFilesWithResponse call +func ParseUploadFilesResponse(rsp *http.Response) (*UploadFilesResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &DeleteFileResponse{ + response := &UploadFilesResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -2797,27 +4053,20 @@ func ParseDeleteFileResponse(rsp *http.Response) (*DeleteFileResponse, error) { return response, nil } -// ParseFileInfoResponse parses an HTTP response from a FileInfoWithResponse call -func ParseFileInfoResponse(rsp *http.Response) (*FileInfoResponse, error) { +// ParseUploadZipResponse parses an HTTP response from a UploadZipWithResponse call +func ParseUploadZipResponse(rsp *http.Response) (*UploadZipResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &FileInfoResponse{ + response := &UploadZipResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest FileInfo - if err := json.Unmarshal(bodyBytes, &dest); err != nil { - return nil, err - } - response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest BadRequestError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -2844,26 +4093,29 @@ func ParseFileInfoResponse(rsp *http.Response) (*FileInfoResponse, error) { return response, nil } -// ParseListFilesResponse parses an HTTP response from a ListFilesWithResponse call -func ParseListFilesResponse(rsp *http.Response) (*ListFilesResponse, error) { +// ParseStartFsWatchResponse parses an HTTP response from a StartFsWatchWithResponse call +func ParseStartFsWatchResponse(rsp *http.Response) (*StartFsWatchResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &ListFilesResponse{ + response := &StartFsWatchResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest ListFiles + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest struct { + // WatchId Unique identifier for the directory watch + WatchId *string `json:"watch_id,omitempty"` + } if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON200 = &dest + response.JSON201 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest BadRequestError @@ -2891,15 +4143,15 @@ func ParseListFilesResponse(rsp *http.Response) (*ListFilesResponse, error) { return response, nil } -// ParseMovePathResponse parses an HTTP response from a MovePathWithResponse call -func ParseMovePathResponse(rsp *http.Response) (*MovePathResponse, error) { +// ParseStopFsWatchResponse parses an HTTP response from a StopFsWatchWithResponse call +func ParseStopFsWatchResponse(rsp *http.Response) (*StopFsWatchResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &MovePathResponse{ + response := &StopFsWatchResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -2931,15 +4183,15 @@ func ParseMovePathResponse(rsp *http.Response) (*MovePathResponse, error) { return response, nil } -// ParseReadFileResponse parses an HTTP response from a ReadFileWithResponse call -func ParseReadFileResponse(rsp *http.Response) (*ReadFileResponse, error) { +// ParseStreamFsEventsResponse parses an HTTP response from a StreamFsEventsWithResponse call +func ParseStreamFsEventsResponse(rsp *http.Response) (*StreamFsEventsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &ReadFileResponse{ + response := &StreamFsEventsResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -2971,15 +4223,15 @@ func ParseReadFileResponse(rsp *http.Response) (*ReadFileResponse, error) { return response, nil } -// ParseSetFilePermissionsResponse parses an HTTP response from a SetFilePermissionsWithResponse call -func ParseSetFilePermissionsResponse(rsp *http.Response) (*SetFilePermissionsResponse, error) { +// ParseWriteFileResponse parses an HTTP response from a WriteFileWithResponse call +func ParseWriteFileResponse(rsp *http.Response) (*WriteFileResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &SetFilePermissionsResponse{ + response := &WriteFileResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -3011,33 +4263,49 @@ func ParseSetFilePermissionsResponse(rsp *http.Response) (*SetFilePermissionsRes return response, nil } -// ParseUploadFilesResponse parses an HTTP response from a UploadFilesWithResponse call -func ParseUploadFilesResponse(rsp *http.Response) (*UploadFilesResponse, error) { +// ParseLogsStreamResponse parses an HTTP response from a LogsStreamWithResponse call +func ParseLogsStreamResponse(rsp *http.Response) (*LogsStreamResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &LogsStreamResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} + +// ParseProcessExecResponse parses an HTTP response from a ProcessExecWithResponse call +func ParseProcessExecResponse(rsp *http.Response) (*ProcessExecResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &UploadFilesResponse{ + response := &ProcessExecResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest BadRequestError + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ProcessExecResult if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON400 = &dest + response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest NotFoundError + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON404 = &dest + response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest InternalError @@ -3051,33 +4319,33 @@ func ParseUploadFilesResponse(rsp *http.Response) (*UploadFilesResponse, error) return response, nil } -// ParseUploadZipResponse parses an HTTP response from a UploadZipWithResponse call -func ParseUploadZipResponse(rsp *http.Response) (*UploadZipResponse, error) { +// ParseProcessSpawnResponse parses an HTTP response from a ProcessSpawnWithResponse call +func ParseProcessSpawnResponse(rsp *http.Response) (*ProcessSpawnResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &UploadZipResponse{ + response := &ProcessSpawnResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: - var dest BadRequestError + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ProcessSpawnResult if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON400 = &dest + response.JSON200 = &dest - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: - var dest NotFoundError + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON404 = &dest + response.JSON400 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: var dest InternalError @@ -3091,29 +4359,26 @@ func ParseUploadZipResponse(rsp *http.Response) (*UploadZipResponse, error) { return response, nil } -// ParseStartFsWatchResponse parses an HTTP response from a StartFsWatchWithResponse call -func ParseStartFsWatchResponse(rsp *http.Response) (*StartFsWatchResponse, error) { +// ParseProcessKillResponse parses an HTTP response from a ProcessKillWithResponse call +func ParseProcessKillResponse(rsp *http.Response) (*ProcessKillResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &StartFsWatchResponse{ + response := &ProcessKillResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { - case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: - var dest struct { - // WatchId Unique identifier for the directory watch - WatchId *string `json:"watch_id,omitempty"` - } + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest OkResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } - response.JSON201 = &dest + response.JSON200 = &dest case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest BadRequestError @@ -3141,20 +4406,27 @@ func ParseStartFsWatchResponse(rsp *http.Response) (*StartFsWatchResponse, error return response, nil } -// ParseStopFsWatchResponse parses an HTTP response from a StopFsWatchWithResponse call -func ParseStopFsWatchResponse(rsp *http.Response) (*StopFsWatchResponse, error) { +// ParseProcessStatusResponse parses an HTTP response from a ProcessStatusWithResponse call +func ParseProcessStatusResponse(rsp *http.Response) (*ProcessStatusResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &StopFsWatchResponse{ + response := &ProcessStatusResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ProcessStatus + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest BadRequestError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -3181,20 +4453,27 @@ func ParseStopFsWatchResponse(rsp *http.Response) (*StopFsWatchResponse, error) return response, nil } -// ParseStreamFsEventsResponse parses an HTTP response from a StreamFsEventsWithResponse call -func ParseStreamFsEventsResponse(rsp *http.Response) (*StreamFsEventsResponse, error) { +// ParseProcessStdinResponse parses an HTTP response from a ProcessStdinWithResponse call +func ParseProcessStdinResponse(rsp *http.Response) (*ProcessStdinResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &StreamFsEventsResponse{ + response := &ProcessStdinResponse{ Body: bodyBytes, HTTPResponse: rsp, } switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ProcessStdinResult + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: var dest BadRequestError if err := json.Unmarshal(bodyBytes, &dest); err != nil { @@ -3221,15 +4500,15 @@ func ParseStreamFsEventsResponse(rsp *http.Response) (*StreamFsEventsResponse, e return response, nil } -// ParseWriteFileResponse parses an HTTP response from a WriteFileWithResponse call -func ParseWriteFileResponse(rsp *http.Response) (*WriteFileResponse, error) { +// ParseProcessStdoutStreamResponse parses an HTTP response from a ProcessStdoutStreamWithResponse call +func ParseProcessStdoutStreamResponse(rsp *http.Response) (*ProcessStdoutStreamResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) defer func() { _ = rsp.Body.Close() }() if err != nil { return nil, err } - response := &WriteFileResponse{ + response := &ProcessStdoutStreamResponse{ Body: bodyBytes, HTTPResponse: rsp, } @@ -3497,6 +4776,27 @@ type ServerInterface interface { // Write or create a file // (PUT /fs/write_file) WriteFile(w http.ResponseWriter, r *http.Request, params WriteFileParams) + // Subscribe to logs via SSE + // (GET /logs/stream) + LogsStream(w http.ResponseWriter, r *http.Request, params LogsStreamParams) + // Execute a command synchronously (optional streaming) + // (POST /process/exec) + ProcessExec(w http.ResponseWriter, r *http.Request) + // Execute a command asynchronously + // (POST /process/spawn) + ProcessSpawn(w http.ResponseWriter, r *http.Request) + // Send signal to process + // (POST /process/{process_id}/kill) + ProcessKill(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) + // Get process status + // (GET /process/{process_id}/status) + ProcessStatus(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) + // Write to process stdin + // (POST /process/{process_id}/stdin) + ProcessStdin(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) + // Stream process stdout/stderr (SSE) + // (GET /process/{process_id}/stdout/stream) + ProcessStdoutStream(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) // Delete a previously recorded video file // (POST /recording/delete) DeleteRecording(w http.ResponseWriter, r *http.Request) @@ -3614,6 +4914,48 @@ func (_ Unimplemented) WriteFile(w http.ResponseWriter, r *http.Request, params w.WriteHeader(http.StatusNotImplemented) } +// Subscribe to logs via SSE +// (GET /logs/stream) +func (_ Unimplemented) LogsStream(w http.ResponseWriter, r *http.Request, params LogsStreamParams) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Execute a command synchronously (optional streaming) +// (POST /process/exec) +func (_ Unimplemented) ProcessExec(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Execute a command asynchronously +// (POST /process/spawn) +func (_ Unimplemented) ProcessSpawn(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Send signal to process +// (POST /process/{process_id}/kill) +func (_ Unimplemented) ProcessKill(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Get process status +// (GET /process/{process_id}/status) +func (_ Unimplemented) ProcessStatus(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Write to process stdin +// (POST /process/{process_id}/stdin) +func (_ Unimplemented) ProcessStdin(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + +// Stream process stdout/stderr (SSE) +// (GET /process/{process_id}/stdout/stream) +func (_ Unimplemented) ProcessStdoutStream(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) { + w.WriteHeader(http.StatusNotImplemented) +} + // Delete a previously recorded video file // (POST /recording/delete) func (_ Unimplemented) DeleteRecording(w http.ResponseWriter, r *http.Request) { @@ -3788,14 +5130,196 @@ func (siw *ServerInterfaceWrapper) ListFiles(w http.ResponseWriter, r *http.Requ handler = middleware(handler) } - handler.ServeHTTP(w, r) -} + handler.ServeHTTP(w, r) +} + +// MovePath operation middleware +func (siw *ServerInterfaceWrapper) MovePath(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.MovePath(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// ReadFile operation middleware +func (siw *ServerInterfaceWrapper) ReadFile(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params ReadFileParams + + // ------------- Required query parameter "path" ------------- + + if paramValue := r.URL.Query().Get("path"); paramValue != "" { + + } else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"}) + return + } + + err = runtime.BindQueryParameter("form", true, true, "path", r.URL.Query(), ¶ms.Path) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ReadFile(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// SetFilePermissions operation middleware +func (siw *ServerInterfaceWrapper) SetFilePermissions(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.SetFilePermissions(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UploadFiles operation middleware +func (siw *ServerInterfaceWrapper) UploadFiles(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UploadFiles(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// UploadZip operation middleware +func (siw *ServerInterfaceWrapper) UploadZip(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.UploadZip(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// StartFsWatch operation middleware +func (siw *ServerInterfaceWrapper) StartFsWatch(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StartFsWatch(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// StopFsWatch operation middleware +func (siw *ServerInterfaceWrapper) StopFsWatch(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "watch_id" ------------- + var watchId string + + err = runtime.BindStyledParameterWithOptions("simple", "watch_id", chi.URLParam(r, "watch_id"), &watchId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "watch_id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StopFsWatch(w, r, watchId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// StreamFsEvents operation middleware +func (siw *ServerInterfaceWrapper) StreamFsEvents(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "watch_id" ------------- + var watchId string + + err = runtime.BindStyledParameterWithOptions("simple", "watch_id", chi.URLParam(r, "watch_id"), &watchId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "watch_id", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.StreamFsEvents(w, r, watchId) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + +// WriteFile operation middleware +func (siw *ServerInterfaceWrapper) WriteFile(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params WriteFileParams + + // ------------- Required query parameter "path" ------------- + + if paramValue := r.URL.Query().Get("path"); paramValue != "" { + + } else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"}) + return + } + + err = runtime.BindQueryParameter("form", true, true, "path", r.URL.Query(), ¶ms.Path) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + return + } + + // ------------- Optional query parameter "mode" ------------- -// MovePath operation middleware -func (siw *ServerInterfaceWrapper) MovePath(w http.ResponseWriter, r *http.Request) { + err = runtime.BindQueryParameter("form", true, false, "mode", r.URL.Query(), ¶ms.Mode) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "mode", Err: err}) + return + } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.MovePath(w, r) + siw.Handler.WriteFile(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3805,45 +5329,55 @@ func (siw *ServerInterfaceWrapper) MovePath(w http.ResponseWriter, r *http.Reque handler.ServeHTTP(w, r) } -// ReadFile operation middleware -func (siw *ServerInterfaceWrapper) ReadFile(w http.ResponseWriter, r *http.Request) { +// LogsStream operation middleware +func (siw *ServerInterfaceWrapper) LogsStream(w http.ResponseWriter, r *http.Request) { var err error // Parameter object where we will unmarshal all parameters from the context - var params ReadFileParams + var params LogsStreamParams - // ------------- Required query parameter "path" ------------- + // ------------- Required query parameter "source" ------------- - if paramValue := r.URL.Query().Get("path"); paramValue != "" { + if paramValue := r.URL.Query().Get("source"); paramValue != "" { } else { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"}) + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "source"}) return } - err = runtime.BindQueryParameter("form", true, true, "path", r.URL.Query(), ¶ms.Path) + err = runtime.BindQueryParameter("form", true, true, "source", r.URL.Query(), ¶ms.Source) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "source", Err: err}) return } - handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.ReadFile(w, r, params) - })) + // ------------- Optional query parameter "follow" ------------- - for _, middleware := range siw.HandlerMiddlewares { - handler = middleware(handler) + err = runtime.BindQueryParameter("form", true, false, "follow", r.URL.Query(), ¶ms.Follow) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "follow", Err: err}) + return } - handler.ServeHTTP(w, r) -} + // ------------- Optional query parameter "path" ------------- -// SetFilePermissions operation middleware -func (siw *ServerInterfaceWrapper) SetFilePermissions(w http.ResponseWriter, r *http.Request) { + err = runtime.BindQueryParameter("form", true, false, "path", r.URL.Query(), ¶ms.Path) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + return + } + + // ------------- Optional query parameter "supervisor_process" ------------- + + err = runtime.BindQueryParameter("form", true, false, "supervisor_process", r.URL.Query(), ¶ms.SupervisorProcess) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "supervisor_process", Err: err}) + return + } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.SetFilePermissions(w, r) + siw.Handler.LogsStream(w, r, params) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3853,11 +5387,11 @@ func (siw *ServerInterfaceWrapper) SetFilePermissions(w http.ResponseWriter, r * handler.ServeHTTP(w, r) } -// UploadFiles operation middleware -func (siw *ServerInterfaceWrapper) UploadFiles(w http.ResponseWriter, r *http.Request) { +// ProcessExec operation middleware +func (siw *ServerInterfaceWrapper) ProcessExec(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UploadFiles(w, r) + siw.Handler.ProcessExec(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3867,11 +5401,11 @@ func (siw *ServerInterfaceWrapper) UploadFiles(w http.ResponseWriter, r *http.Re handler.ServeHTTP(w, r) } -// UploadZip operation middleware -func (siw *ServerInterfaceWrapper) UploadZip(w http.ResponseWriter, r *http.Request) { +// ProcessSpawn operation middleware +func (siw *ServerInterfaceWrapper) ProcessSpawn(w http.ResponseWriter, r *http.Request) { handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.UploadZip(w, r) + siw.Handler.ProcessSpawn(w, r) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3881,11 +5415,22 @@ func (siw *ServerInterfaceWrapper) UploadZip(w http.ResponseWriter, r *http.Requ handler.ServeHTTP(w, r) } -// StartFsWatch operation middleware -func (siw *ServerInterfaceWrapper) StartFsWatch(w http.ResponseWriter, r *http.Request) { +// ProcessKill operation middleware +func (siw *ServerInterfaceWrapper) ProcessKill(w http.ResponseWriter, r *http.Request) { + + var err error + + // ------------- Path parameter "process_id" ------------- + var processId openapi_types.UUID + + err = runtime.BindStyledParameterWithOptions("simple", "process_id", chi.URLParam(r, "process_id"), &processId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "process_id", Err: err}) + return + } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.StartFsWatch(w, r) + siw.Handler.ProcessKill(w, r, processId) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3895,22 +5440,22 @@ func (siw *ServerInterfaceWrapper) StartFsWatch(w http.ResponseWriter, r *http.R handler.ServeHTTP(w, r) } -// StopFsWatch operation middleware -func (siw *ServerInterfaceWrapper) StopFsWatch(w http.ResponseWriter, r *http.Request) { +// ProcessStatus operation middleware +func (siw *ServerInterfaceWrapper) ProcessStatus(w http.ResponseWriter, r *http.Request) { var err error - // ------------- Path parameter "watch_id" ------------- - var watchId string + // ------------- Path parameter "process_id" ------------- + var processId openapi_types.UUID - err = runtime.BindStyledParameterWithOptions("simple", "watch_id", chi.URLParam(r, "watch_id"), &watchId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + err = runtime.BindStyledParameterWithOptions("simple", "process_id", chi.URLParam(r, "process_id"), &processId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "watch_id", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "process_id", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.StopFsWatch(w, r, watchId) + siw.Handler.ProcessStatus(w, r, processId) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3920,22 +5465,22 @@ func (siw *ServerInterfaceWrapper) StopFsWatch(w http.ResponseWriter, r *http.Re handler.ServeHTTP(w, r) } -// StreamFsEvents operation middleware -func (siw *ServerInterfaceWrapper) StreamFsEvents(w http.ResponseWriter, r *http.Request) { +// ProcessStdin operation middleware +func (siw *ServerInterfaceWrapper) ProcessStdin(w http.ResponseWriter, r *http.Request) { var err error - // ------------- Path parameter "watch_id" ------------- - var watchId string + // ------------- Path parameter "process_id" ------------- + var processId openapi_types.UUID - err = runtime.BindStyledParameterWithOptions("simple", "watch_id", chi.URLParam(r, "watch_id"), &watchId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) + err = runtime.BindStyledParameterWithOptions("simple", "process_id", chi.URLParam(r, "process_id"), &processId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "watch_id", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "process_id", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.StreamFsEvents(w, r, watchId) + siw.Handler.ProcessStdin(w, r, processId) })) for _, middleware := range siw.HandlerMiddlewares { @@ -3945,39 +5490,22 @@ func (siw *ServerInterfaceWrapper) StreamFsEvents(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } -// WriteFile operation middleware -func (siw *ServerInterfaceWrapper) WriteFile(w http.ResponseWriter, r *http.Request) { +// ProcessStdoutStream operation middleware +func (siw *ServerInterfaceWrapper) ProcessStdoutStream(w http.ResponseWriter, r *http.Request) { var err error - // Parameter object where we will unmarshal all parameters from the context - var params WriteFileParams - - // ------------- Required query parameter "path" ------------- - - if paramValue := r.URL.Query().Get("path"); paramValue != "" { - - } else { - siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"}) - return - } - - err = runtime.BindQueryParameter("form", true, true, "path", r.URL.Query(), ¶ms.Path) - if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) - return - } - - // ------------- Optional query parameter "mode" ------------- + // ------------- Path parameter "process_id" ------------- + var processId openapi_types.UUID - err = runtime.BindQueryParameter("form", true, false, "mode", r.URL.Query(), ¶ms.Mode) + err = runtime.BindStyledParameterWithOptions("simple", "process_id", chi.URLParam(r, "process_id"), &processId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true}) if err != nil { - siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "mode", Err: err}) + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "process_id", Err: err}) return } handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - siw.Handler.WriteFile(w, r, params) + siw.Handler.ProcessStdoutStream(w, r, processId) })) for _, middleware := range siw.HandlerMiddlewares { @@ -4231,6 +5759,27 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Put(options.BaseURL+"/fs/write_file", wrapper.WriteFile) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/logs/stream", wrapper.LogsStream) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/process/exec", wrapper.ProcessExec) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/process/spawn", wrapper.ProcessSpawn) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/process/{process_id}/kill", wrapper.ProcessKill) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/process/{process_id}/status", wrapper.ProcessStatus) + }) + r.Group(func(r chi.Router) { + r.Post(options.BaseURL+"/process/{process_id}/stdin", wrapper.ProcessStdin) + }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/process/{process_id}/stdout/stream", wrapper.ProcessStdoutStream) + }) r.Group(func(r chi.Router) { r.Post(options.BaseURL+"/recording/delete", wrapper.DeleteRecording) }) @@ -4247,628 +5796,1013 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Post(options.BaseURL+"/recording/stop", wrapper.StopRecording) }) - return r + return r +} + +type BadRequestErrorJSONResponse Error + +type ConflictErrorJSONResponse Error + +type InternalErrorJSONResponse Error + +type NotFoundErrorJSONResponse Error + +type ClickMouseRequestObject struct { + Body *ClickMouseJSONRequestBody +} + +type ClickMouseResponseObject interface { + VisitClickMouseResponse(w http.ResponseWriter) error +} + +type ClickMouse200Response struct { +} + +func (response ClickMouse200Response) VisitClickMouseResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type ClickMouse400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response ClickMouse400JSONResponse) VisitClickMouseResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type ClickMouse500JSONResponse struct{ InternalErrorJSONResponse } + +func (response ClickMouse500JSONResponse) VisitClickMouseResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type MoveMouseRequestObject struct { + Body *MoveMouseJSONRequestBody +} + +type MoveMouseResponseObject interface { + VisitMoveMouseResponse(w http.ResponseWriter) error +} + +type MoveMouse200Response struct { +} + +func (response MoveMouse200Response) VisitMoveMouseResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type MoveMouse400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response MoveMouse400JSONResponse) VisitMoveMouseResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type MoveMouse500JSONResponse struct{ InternalErrorJSONResponse } + +func (response MoveMouse500JSONResponse) VisitMoveMouseResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type CreateDirectoryRequestObject struct { + Body *CreateDirectoryJSONRequestBody +} + +type CreateDirectoryResponseObject interface { + VisitCreateDirectoryResponse(w http.ResponseWriter) error +} + +type CreateDirectory201Response struct { +} + +func (response CreateDirectory201Response) VisitCreateDirectoryResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil +} + +type CreateDirectory400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response CreateDirectory400JSONResponse) VisitCreateDirectoryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type CreateDirectory500JSONResponse struct{ InternalErrorJSONResponse } + +func (response CreateDirectory500JSONResponse) VisitCreateDirectoryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteDirectoryRequestObject struct { + Body *DeleteDirectoryJSONRequestBody +} + +type DeleteDirectoryResponseObject interface { + VisitDeleteDirectoryResponse(w http.ResponseWriter) error +} + +type DeleteDirectory200Response struct { +} + +func (response DeleteDirectory200Response) VisitDeleteDirectoryResponse(w http.ResponseWriter) error { + w.WriteHeader(200) + return nil +} + +type DeleteDirectory400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response DeleteDirectory400JSONResponse) VisitDeleteDirectoryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteDirectory404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response DeleteDirectory404JSONResponse) VisitDeleteDirectoryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) } -type BadRequestErrorJSONResponse Error - -type ConflictErrorJSONResponse Error +type DeleteDirectory500JSONResponse struct{ InternalErrorJSONResponse } -type InternalErrorJSONResponse Error +func (response DeleteDirectory500JSONResponse) VisitDeleteDirectoryResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) -type NotFoundErrorJSONResponse Error + return json.NewEncoder(w).Encode(response) +} -type ClickMouseRequestObject struct { - Body *ClickMouseJSONRequestBody +type DeleteFileRequestObject struct { + Body *DeleteFileJSONRequestBody } -type ClickMouseResponseObject interface { - VisitClickMouseResponse(w http.ResponseWriter) error +type DeleteFileResponseObject interface { + VisitDeleteFileResponse(w http.ResponseWriter) error } -type ClickMouse200Response struct { +type DeleteFile200Response struct { } -func (response ClickMouse200Response) VisitClickMouseResponse(w http.ResponseWriter) error { +func (response DeleteFile200Response) VisitDeleteFileResponse(w http.ResponseWriter) error { w.WriteHeader(200) return nil } -type ClickMouse400JSONResponse struct{ BadRequestErrorJSONResponse } +type DeleteFile400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response ClickMouse400JSONResponse) VisitClickMouseResponse(w http.ResponseWriter) error { +func (response DeleteFile400JSONResponse) VisitDeleteFileResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type ClickMouse500JSONResponse struct{ InternalErrorJSONResponse } +type DeleteFile404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response ClickMouse500JSONResponse) VisitClickMouseResponse(w http.ResponseWriter) error { +func (response DeleteFile404JSONResponse) VisitDeleteFileResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type DeleteFile500JSONResponse struct{ InternalErrorJSONResponse } + +func (response DeleteFile500JSONResponse) VisitDeleteFileResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type MoveMouseRequestObject struct { - Body *MoveMouseJSONRequestBody +type FileInfoRequestObject struct { + Params FileInfoParams } -type MoveMouseResponseObject interface { - VisitMoveMouseResponse(w http.ResponseWriter) error +type FileInfoResponseObject interface { + VisitFileInfoResponse(w http.ResponseWriter) error } -type MoveMouse200Response struct { +type FileInfo200JSONResponse FileInfo + +func (response FileInfo200JSONResponse) VisitFileInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) } -func (response MoveMouse200Response) VisitMoveMouseResponse(w http.ResponseWriter) error { +type FileInfo400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response FileInfo400JSONResponse) VisitFileInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type FileInfo404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response FileInfo404JSONResponse) VisitFileInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type FileInfo500JSONResponse struct{ InternalErrorJSONResponse } + +func (response FileInfo500JSONResponse) VisitFileInfoResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type ListFilesRequestObject struct { + Params ListFilesParams +} + +type ListFilesResponseObject interface { + VisitListFilesResponse(w http.ResponseWriter) error +} + +type ListFiles200JSONResponse ListFiles + +func (response ListFiles200JSONResponse) VisitListFilesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(200) - return nil + + return json.NewEncoder(w).Encode(response) } -type MoveMouse400JSONResponse struct{ BadRequestErrorJSONResponse } +type ListFiles400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response MoveMouse400JSONResponse) VisitMoveMouseResponse(w http.ResponseWriter) error { +func (response ListFiles400JSONResponse) VisitListFilesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type MoveMouse500JSONResponse struct{ InternalErrorJSONResponse } +type ListFiles404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response MoveMouse500JSONResponse) VisitMoveMouseResponse(w http.ResponseWriter) error { +func (response ListFiles404JSONResponse) VisitListFilesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type ListFiles500JSONResponse struct{ InternalErrorJSONResponse } + +func (response ListFiles500JSONResponse) VisitListFilesResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type CreateDirectoryRequestObject struct { - Body *CreateDirectoryJSONRequestBody +type MovePathRequestObject struct { + Body *MovePathJSONRequestBody } -type CreateDirectoryResponseObject interface { - VisitCreateDirectoryResponse(w http.ResponseWriter) error +type MovePathResponseObject interface { + VisitMovePathResponse(w http.ResponseWriter) error } -type CreateDirectory201Response struct { +type MovePath200Response struct { } -func (response CreateDirectory201Response) VisitCreateDirectoryResponse(w http.ResponseWriter) error { - w.WriteHeader(201) +func (response MovePath200Response) VisitMovePathResponse(w http.ResponseWriter) error { + w.WriteHeader(200) return nil } -type CreateDirectory400JSONResponse struct{ BadRequestErrorJSONResponse } +type MovePath400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response CreateDirectory400JSONResponse) VisitCreateDirectoryResponse(w http.ResponseWriter) error { +func (response MovePath400JSONResponse) VisitMovePathResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type CreateDirectory500JSONResponse struct{ InternalErrorJSONResponse } +type MovePath404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response CreateDirectory500JSONResponse) VisitCreateDirectoryResponse(w http.ResponseWriter) error { +func (response MovePath404JSONResponse) VisitMovePathResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type MovePath500JSONResponse struct{ InternalErrorJSONResponse } + +func (response MovePath500JSONResponse) VisitMovePathResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type DeleteDirectoryRequestObject struct { - Body *DeleteDirectoryJSONRequestBody +type ReadFileRequestObject struct { + Params ReadFileParams } -type DeleteDirectoryResponseObject interface { - VisitDeleteDirectoryResponse(w http.ResponseWriter) error +type ReadFileResponseObject interface { + VisitReadFileResponse(w http.ResponseWriter) error } -type DeleteDirectory200Response struct { +type ReadFile200ApplicationoctetStreamResponse struct { + Body io.Reader + ContentLength int64 } -func (response DeleteDirectory200Response) VisitDeleteDirectoryResponse(w http.ResponseWriter) error { +func (response ReadFile200ApplicationoctetStreamResponse) VisitReadFileResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/octet-stream") + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } w.WriteHeader(200) - return nil + + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + _, err := io.Copy(w, response.Body) + return err } -type DeleteDirectory400JSONResponse struct{ BadRequestErrorJSONResponse } +type ReadFile400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response DeleteDirectory400JSONResponse) VisitDeleteDirectoryResponse(w http.ResponseWriter) error { +func (response ReadFile400JSONResponse) VisitReadFileResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type DeleteDirectory404JSONResponse struct{ NotFoundErrorJSONResponse } +type ReadFile404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response DeleteDirectory404JSONResponse) VisitDeleteDirectoryResponse(w http.ResponseWriter) error { +func (response ReadFile404JSONResponse) VisitReadFileResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type DeleteDirectory500JSONResponse struct{ InternalErrorJSONResponse } +type ReadFile500JSONResponse struct{ InternalErrorJSONResponse } -func (response DeleteDirectory500JSONResponse) VisitDeleteDirectoryResponse(w http.ResponseWriter) error { +func (response ReadFile500JSONResponse) VisitReadFileResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type DeleteFileRequestObject struct { - Body *DeleteFileJSONRequestBody +type SetFilePermissionsRequestObject struct { + Body *SetFilePermissionsJSONRequestBody } -type DeleteFileResponseObject interface { - VisitDeleteFileResponse(w http.ResponseWriter) error +type SetFilePermissionsResponseObject interface { + VisitSetFilePermissionsResponse(w http.ResponseWriter) error } -type DeleteFile200Response struct { +type SetFilePermissions200Response struct { } -func (response DeleteFile200Response) VisitDeleteFileResponse(w http.ResponseWriter) error { +func (response SetFilePermissions200Response) VisitSetFilePermissionsResponse(w http.ResponseWriter) error { w.WriteHeader(200) return nil } -type DeleteFile400JSONResponse struct{ BadRequestErrorJSONResponse } +type SetFilePermissions400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response DeleteFile400JSONResponse) VisitDeleteFileResponse(w http.ResponseWriter) error { +func (response SetFilePermissions400JSONResponse) VisitSetFilePermissionsResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type DeleteFile404JSONResponse struct{ NotFoundErrorJSONResponse } +type SetFilePermissions404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response DeleteFile404JSONResponse) VisitDeleteFileResponse(w http.ResponseWriter) error { +func (response SetFilePermissions404JSONResponse) VisitSetFilePermissionsResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type DeleteFile500JSONResponse struct{ InternalErrorJSONResponse } +type SetFilePermissions500JSONResponse struct{ InternalErrorJSONResponse } -func (response DeleteFile500JSONResponse) VisitDeleteFileResponse(w http.ResponseWriter) error { +func (response SetFilePermissions500JSONResponse) VisitSetFilePermissionsResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type FileInfoRequestObject struct { - Params FileInfoParams +type UploadFilesRequestObject struct { + Body *multipart.Reader } -type FileInfoResponseObject interface { - VisitFileInfoResponse(w http.ResponseWriter) error +type UploadFilesResponseObject interface { + VisitUploadFilesResponse(w http.ResponseWriter) error +} + +type UploadFiles201Response struct { +} + +func (response UploadFiles201Response) VisitUploadFilesResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil +} + +type UploadFiles400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response UploadFiles400JSONResponse) VisitUploadFilesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type UploadFiles404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response UploadFiles404JSONResponse) VisitUploadFilesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type UploadFiles500JSONResponse struct{ InternalErrorJSONResponse } + +func (response UploadFiles500JSONResponse) VisitUploadFilesResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type UploadZipRequestObject struct { + Body *multipart.Reader } -type FileInfo200JSONResponse FileInfo +type UploadZipResponseObject interface { + VisitUploadZipResponse(w http.ResponseWriter) error +} -func (response FileInfo200JSONResponse) VisitFileInfoResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) +type UploadZip201Response struct { +} - return json.NewEncoder(w).Encode(response) +func (response UploadZip201Response) VisitUploadZipResponse(w http.ResponseWriter) error { + w.WriteHeader(201) + return nil } -type FileInfo400JSONResponse struct{ BadRequestErrorJSONResponse } +type UploadZip400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response FileInfo400JSONResponse) VisitFileInfoResponse(w http.ResponseWriter) error { +func (response UploadZip400JSONResponse) VisitUploadZipResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type FileInfo404JSONResponse struct{ NotFoundErrorJSONResponse } +type UploadZip404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response FileInfo404JSONResponse) VisitFileInfoResponse(w http.ResponseWriter) error { +func (response UploadZip404JSONResponse) VisitUploadZipResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type FileInfo500JSONResponse struct{ InternalErrorJSONResponse } +type UploadZip500JSONResponse struct{ InternalErrorJSONResponse } -func (response FileInfo500JSONResponse) VisitFileInfoResponse(w http.ResponseWriter) error { +func (response UploadZip500JSONResponse) VisitUploadZipResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type ListFilesRequestObject struct { - Params ListFilesParams +type StartFsWatchRequestObject struct { + Body *StartFsWatchJSONRequestBody } -type ListFilesResponseObject interface { - VisitListFilesResponse(w http.ResponseWriter) error +type StartFsWatchResponseObject interface { + VisitStartFsWatchResponse(w http.ResponseWriter) error } -type ListFiles200JSONResponse ListFiles +type StartFsWatch201JSONResponse struct { + // WatchId Unique identifier for the directory watch + WatchId *string `json:"watch_id,omitempty"` +} -func (response ListFiles200JSONResponse) VisitListFilesResponse(w http.ResponseWriter) error { +func (response StartFsWatch201JSONResponse) VisitStartFsWatchResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(200) + w.WriteHeader(201) return json.NewEncoder(w).Encode(response) } -type ListFiles400JSONResponse struct{ BadRequestErrorJSONResponse } +type StartFsWatch400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response ListFiles400JSONResponse) VisitListFilesResponse(w http.ResponseWriter) error { +func (response StartFsWatch400JSONResponse) VisitStartFsWatchResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type ListFiles404JSONResponse struct{ NotFoundErrorJSONResponse } +type StartFsWatch404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response ListFiles404JSONResponse) VisitListFilesResponse(w http.ResponseWriter) error { +func (response StartFsWatch404JSONResponse) VisitStartFsWatchResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type ListFiles500JSONResponse struct{ InternalErrorJSONResponse } +type StartFsWatch500JSONResponse struct{ InternalErrorJSONResponse } -func (response ListFiles500JSONResponse) VisitListFilesResponse(w http.ResponseWriter) error { +func (response StartFsWatch500JSONResponse) VisitStartFsWatchResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type MovePathRequestObject struct { - Body *MovePathJSONRequestBody +type StopFsWatchRequestObject struct { + WatchId string `json:"watch_id"` } -type MovePathResponseObject interface { - VisitMovePathResponse(w http.ResponseWriter) error +type StopFsWatchResponseObject interface { + VisitStopFsWatchResponse(w http.ResponseWriter) error } -type MovePath200Response struct { +type StopFsWatch204Response struct { } -func (response MovePath200Response) VisitMovePathResponse(w http.ResponseWriter) error { - w.WriteHeader(200) +func (response StopFsWatch204Response) VisitStopFsWatchResponse(w http.ResponseWriter) error { + w.WriteHeader(204) return nil } -type MovePath400JSONResponse struct{ BadRequestErrorJSONResponse } +type StopFsWatch400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response MovePath400JSONResponse) VisitMovePathResponse(w http.ResponseWriter) error { +func (response StopFsWatch400JSONResponse) VisitStopFsWatchResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type MovePath404JSONResponse struct{ NotFoundErrorJSONResponse } +type StopFsWatch404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response MovePath404JSONResponse) VisitMovePathResponse(w http.ResponseWriter) error { +func (response StopFsWatch404JSONResponse) VisitStopFsWatchResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type MovePath500JSONResponse struct{ InternalErrorJSONResponse } +type StopFsWatch500JSONResponse struct{ InternalErrorJSONResponse } -func (response MovePath500JSONResponse) VisitMovePathResponse(w http.ResponseWriter) error { +func (response StopFsWatch500JSONResponse) VisitStopFsWatchResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type ReadFileRequestObject struct { - Params ReadFileParams +type StreamFsEventsRequestObject struct { + WatchId string `json:"watch_id"` } -type ReadFileResponseObject interface { - VisitReadFileResponse(w http.ResponseWriter) error +type StreamFsEventsResponseObject interface { + VisitStreamFsEventsResponse(w http.ResponseWriter) error } -type ReadFile200ApplicationoctetStreamResponse struct { +type StreamFsEvents200ResponseHeaders struct { + XSSEContentType string +} + +type StreamFsEvents200TexteventStreamResponse struct { Body io.Reader + Headers StreamFsEvents200ResponseHeaders ContentLength int64 } -func (response ReadFile200ApplicationoctetStreamResponse) VisitReadFileResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/octet-stream") +func (response StreamFsEvents200TexteventStreamResponse) VisitStreamFsEventsResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "text/event-stream") if response.ContentLength != 0 { w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) } + w.Header().Set("X-SSE-Content-Type", fmt.Sprint(response.Headers.XSSEContentType)) w.WriteHeader(200) if closer, ok := response.Body.(io.ReadCloser); ok { defer closer.Close() } - _, err := io.Copy(w, response.Body) - return err + flusher, ok := w.(http.Flusher) + if !ok { + // If w doesn't support flushing, might as well use io.Copy + _, err := io.Copy(w, response.Body) + return err + } + + // Use a buffer for efficient copying and flushing + buf := make([]byte, 4096) // text/event-stream are usually very small messages + for { + n, err := response.Body.Read(buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + return werr + } + flusher.Flush() // Flush after each write + } + if err != nil { + if err == io.EOF { + return nil // End of file, no error + } + return err + } + } } -type ReadFile400JSONResponse struct{ BadRequestErrorJSONResponse } +type StreamFsEvents400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response ReadFile400JSONResponse) VisitReadFileResponse(w http.ResponseWriter) error { +func (response StreamFsEvents400JSONResponse) VisitStreamFsEventsResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type ReadFile404JSONResponse struct{ NotFoundErrorJSONResponse } +type StreamFsEvents404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response ReadFile404JSONResponse) VisitReadFileResponse(w http.ResponseWriter) error { +func (response StreamFsEvents404JSONResponse) VisitStreamFsEventsResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type ReadFile500JSONResponse struct{ InternalErrorJSONResponse } +type StreamFsEvents500JSONResponse struct{ InternalErrorJSONResponse } -func (response ReadFile500JSONResponse) VisitReadFileResponse(w http.ResponseWriter) error { +func (response StreamFsEvents500JSONResponse) VisitStreamFsEventsResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type SetFilePermissionsRequestObject struct { - Body *SetFilePermissionsJSONRequestBody +type WriteFileRequestObject struct { + Params WriteFileParams + Body io.Reader } -type SetFilePermissionsResponseObject interface { - VisitSetFilePermissionsResponse(w http.ResponseWriter) error +type WriteFileResponseObject interface { + VisitWriteFileResponse(w http.ResponseWriter) error } -type SetFilePermissions200Response struct { +type WriteFile201Response struct { } -func (response SetFilePermissions200Response) VisitSetFilePermissionsResponse(w http.ResponseWriter) error { - w.WriteHeader(200) +func (response WriteFile201Response) VisitWriteFileResponse(w http.ResponseWriter) error { + w.WriteHeader(201) return nil } -type SetFilePermissions400JSONResponse struct{ BadRequestErrorJSONResponse } +type WriteFile400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response SetFilePermissions400JSONResponse) VisitSetFilePermissionsResponse(w http.ResponseWriter) error { +func (response WriteFile400JSONResponse) VisitWriteFileResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type SetFilePermissions404JSONResponse struct{ NotFoundErrorJSONResponse } +type WriteFile404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response SetFilePermissions404JSONResponse) VisitSetFilePermissionsResponse(w http.ResponseWriter) error { +func (response WriteFile404JSONResponse) VisitWriteFileResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type SetFilePermissions500JSONResponse struct{ InternalErrorJSONResponse } +type WriteFile500JSONResponse struct{ InternalErrorJSONResponse } -func (response SetFilePermissions500JSONResponse) VisitSetFilePermissionsResponse(w http.ResponseWriter) error { +func (response WriteFile500JSONResponse) VisitWriteFileResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type UploadFilesRequestObject struct { - Body *multipart.Reader +type LogsStreamRequestObject struct { + Params LogsStreamParams } -type UploadFilesResponseObject interface { - VisitUploadFilesResponse(w http.ResponseWriter) error +type LogsStreamResponseObject interface { + VisitLogsStreamResponse(w http.ResponseWriter) error } -type UploadFiles201Response struct { +type LogsStream200ResponseHeaders struct { + XSSEContentType string } -func (response UploadFiles201Response) VisitUploadFilesResponse(w http.ResponseWriter) error { - w.WriteHeader(201) - return nil +type LogsStream200TexteventStreamResponse struct { + Body io.Reader + Headers LogsStream200ResponseHeaders + ContentLength int64 } -type UploadFiles400JSONResponse struct{ BadRequestErrorJSONResponse } +func (response LogsStream200TexteventStreamResponse) VisitLogsStreamResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "text/event-stream") + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } + w.Header().Set("X-SSE-Content-Type", fmt.Sprint(response.Headers.XSSEContentType)) + w.WriteHeader(200) -func (response UploadFiles400JSONResponse) VisitUploadFilesResponse(w http.ResponseWriter) error { + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + flusher, ok := w.(http.Flusher) + if !ok { + // If w doesn't support flushing, might as well use io.Copy + _, err := io.Copy(w, response.Body) + return err + } + + // Use a buffer for efficient copying and flushing + buf := make([]byte, 4096) // text/event-stream are usually very small messages + for { + n, err := response.Body.Read(buf) + if n > 0 { + if _, werr := w.Write(buf[:n]); werr != nil { + return werr + } + flusher.Flush() // Flush after each write + } + if err != nil { + if err == io.EOF { + return nil // End of file, no error + } + return err + } + } +} + +type ProcessExecRequestObject struct { + Body *ProcessExecJSONRequestBody +} + +type ProcessExecResponseObject interface { + VisitProcessExecResponse(w http.ResponseWriter) error +} + +type ProcessExec200JSONResponse ProcessExecResult + +func (response ProcessExec200JSONResponse) VisitProcessExecResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(400) + w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type UploadFiles404JSONResponse struct{ NotFoundErrorJSONResponse } +type ProcessExec400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response UploadFiles404JSONResponse) VisitUploadFilesResponse(w http.ResponseWriter) error { +func (response ProcessExec400JSONResponse) VisitProcessExecResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(404) + w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type UploadFiles500JSONResponse struct{ InternalErrorJSONResponse } +type ProcessExec500JSONResponse struct{ InternalErrorJSONResponse } -func (response UploadFiles500JSONResponse) VisitUploadFilesResponse(w http.ResponseWriter) error { +func (response ProcessExec500JSONResponse) VisitProcessExecResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type UploadZipRequestObject struct { - Body *multipart.Reader +type ProcessSpawnRequestObject struct { + Body *ProcessSpawnJSONRequestBody } -type UploadZipResponseObject interface { - VisitUploadZipResponse(w http.ResponseWriter) error +type ProcessSpawnResponseObject interface { + VisitProcessSpawnResponse(w http.ResponseWriter) error } -type UploadZip201Response struct { +type ProcessSpawn200JSONResponse ProcessSpawnResult + +func (response ProcessSpawn200JSONResponse) VisitProcessSpawnResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) +} + +type ProcessSpawn400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response ProcessSpawn400JSONResponse) VisitProcessSpawnResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type ProcessSpawn500JSONResponse struct{ InternalErrorJSONResponse } + +func (response ProcessSpawn500JSONResponse) VisitProcessSpawnResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + +type ProcessKillRequestObject struct { + ProcessId openapi_types.UUID `json:"process_id"` + Body *ProcessKillJSONRequestBody +} + +type ProcessKillResponseObject interface { + VisitProcessKillResponse(w http.ResponseWriter) error } -func (response UploadZip201Response) VisitUploadZipResponse(w http.ResponseWriter) error { - w.WriteHeader(201) - return nil +type ProcessKill200JSONResponse OkResponse + +func (response ProcessKill200JSONResponse) VisitProcessKillResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) } -type UploadZip400JSONResponse struct{ BadRequestErrorJSONResponse } +type ProcessKill400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response UploadZip400JSONResponse) VisitUploadZipResponse(w http.ResponseWriter) error { +func (response ProcessKill400JSONResponse) VisitProcessKillResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type UploadZip404JSONResponse struct{ NotFoundErrorJSONResponse } +type ProcessKill404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response UploadZip404JSONResponse) VisitUploadZipResponse(w http.ResponseWriter) error { +func (response ProcessKill404JSONResponse) VisitProcessKillResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type UploadZip500JSONResponse struct{ InternalErrorJSONResponse } +type ProcessKill500JSONResponse struct{ InternalErrorJSONResponse } -func (response UploadZip500JSONResponse) VisitUploadZipResponse(w http.ResponseWriter) error { +func (response ProcessKill500JSONResponse) VisitProcessKillResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type StartFsWatchRequestObject struct { - Body *StartFsWatchJSONRequestBody +type ProcessStatusRequestObject struct { + ProcessId openapi_types.UUID `json:"process_id"` } -type StartFsWatchResponseObject interface { - VisitStartFsWatchResponse(w http.ResponseWriter) error +type ProcessStatusResponseObject interface { + VisitProcessStatusResponse(w http.ResponseWriter) error } -type StartFsWatch201JSONResponse struct { - // WatchId Unique identifier for the directory watch - WatchId *string `json:"watch_id,omitempty"` -} +type ProcessStatus200JSONResponse ProcessStatus -func (response StartFsWatch201JSONResponse) VisitStartFsWatchResponse(w http.ResponseWriter) error { +func (response ProcessStatus200JSONResponse) VisitProcessStatusResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(201) + w.WriteHeader(200) return json.NewEncoder(w).Encode(response) } -type StartFsWatch400JSONResponse struct{ BadRequestErrorJSONResponse } +type ProcessStatus400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response StartFsWatch400JSONResponse) VisitStartFsWatchResponse(w http.ResponseWriter) error { +func (response ProcessStatus400JSONResponse) VisitProcessStatusResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type StartFsWatch404JSONResponse struct{ NotFoundErrorJSONResponse } +type ProcessStatus404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response StartFsWatch404JSONResponse) VisitStartFsWatchResponse(w http.ResponseWriter) error { +func (response ProcessStatus404JSONResponse) VisitProcessStatusResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type StartFsWatch500JSONResponse struct{ InternalErrorJSONResponse } +type ProcessStatus500JSONResponse struct{ InternalErrorJSONResponse } -func (response StartFsWatch500JSONResponse) VisitStartFsWatchResponse(w http.ResponseWriter) error { +func (response ProcessStatus500JSONResponse) VisitProcessStatusResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type StopFsWatchRequestObject struct { - WatchId string `json:"watch_id"` +type ProcessStdinRequestObject struct { + ProcessId openapi_types.UUID `json:"process_id"` + Body *ProcessStdinJSONRequestBody } -type StopFsWatchResponseObject interface { - VisitStopFsWatchResponse(w http.ResponseWriter) error +type ProcessStdinResponseObject interface { + VisitProcessStdinResponse(w http.ResponseWriter) error } -type StopFsWatch204Response struct { -} +type ProcessStdin200JSONResponse ProcessStdinResult -func (response StopFsWatch204Response) VisitStopFsWatchResponse(w http.ResponseWriter) error { - w.WriteHeader(204) - return nil +func (response ProcessStdin200JSONResponse) VisitProcessStdinResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + + return json.NewEncoder(w).Encode(response) } -type StopFsWatch400JSONResponse struct{ BadRequestErrorJSONResponse } +type ProcessStdin400JSONResponse struct{ BadRequestErrorJSONResponse } -func (response StopFsWatch400JSONResponse) VisitStopFsWatchResponse(w http.ResponseWriter) error { +func (response ProcessStdin400JSONResponse) VisitProcessStdinResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type StopFsWatch404JSONResponse struct{ NotFoundErrorJSONResponse } +type ProcessStdin404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response StopFsWatch404JSONResponse) VisitStopFsWatchResponse(w http.ResponseWriter) error { +func (response ProcessStdin404JSONResponse) VisitProcessStdinResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type StopFsWatch500JSONResponse struct{ InternalErrorJSONResponse } +type ProcessStdin500JSONResponse struct{ InternalErrorJSONResponse } -func (response StopFsWatch500JSONResponse) VisitStopFsWatchResponse(w http.ResponseWriter) error { +func (response ProcessStdin500JSONResponse) VisitProcessStdinResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) return json.NewEncoder(w).Encode(response) } -type StreamFsEventsRequestObject struct { - WatchId string `json:"watch_id"` +type ProcessStdoutStreamRequestObject struct { + ProcessId openapi_types.UUID `json:"process_id"` } -type StreamFsEventsResponseObject interface { - VisitStreamFsEventsResponse(w http.ResponseWriter) error +type ProcessStdoutStreamResponseObject interface { + VisitProcessStdoutStreamResponse(w http.ResponseWriter) error } -type StreamFsEvents200ResponseHeaders struct { +type ProcessStdoutStream200ResponseHeaders struct { XSSEContentType string } -type StreamFsEvents200TexteventStreamResponse struct { +type ProcessStdoutStream200TexteventStreamResponse struct { Body io.Reader - Headers StreamFsEvents200ResponseHeaders + Headers ProcessStdoutStream200ResponseHeaders ContentLength int64 } -func (response StreamFsEvents200TexteventStreamResponse) VisitStreamFsEventsResponse(w http.ResponseWriter) error { +func (response ProcessStdoutStream200TexteventStreamResponse) VisitProcessStdoutStreamResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "text/event-stream") if response.ContentLength != 0 { w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) @@ -4905,71 +6839,27 @@ func (response StreamFsEvents200TexteventStreamResponse) VisitStreamFsEventsResp } } -type StreamFsEvents400JSONResponse struct{ BadRequestErrorJSONResponse } - -func (response StreamFsEvents400JSONResponse) VisitStreamFsEventsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(400) - - return json.NewEncoder(w).Encode(response) -} - -type StreamFsEvents404JSONResponse struct{ NotFoundErrorJSONResponse } - -func (response StreamFsEvents404JSONResponse) VisitStreamFsEventsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(404) - - return json.NewEncoder(w).Encode(response) -} - -type StreamFsEvents500JSONResponse struct{ InternalErrorJSONResponse } - -func (response StreamFsEvents500JSONResponse) VisitStreamFsEventsResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(500) - - return json.NewEncoder(w).Encode(response) -} - -type WriteFileRequestObject struct { - Params WriteFileParams - Body io.Reader -} - -type WriteFileResponseObject interface { - VisitWriteFileResponse(w http.ResponseWriter) error -} - -type WriteFile201Response struct { -} - -func (response WriteFile201Response) VisitWriteFileResponse(w http.ResponseWriter) error { - w.WriteHeader(201) - return nil -} +type ProcessStdoutStream400JSONResponse struct{ BadRequestErrorJSONResponse } -type WriteFile400JSONResponse struct{ BadRequestErrorJSONResponse } - -func (response WriteFile400JSONResponse) VisitWriteFileResponse(w http.ResponseWriter) error { +func (response ProcessStdoutStream400JSONResponse) VisitProcessStdoutStreamResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(400) return json.NewEncoder(w).Encode(response) } -type WriteFile404JSONResponse struct{ NotFoundErrorJSONResponse } +type ProcessStdoutStream404JSONResponse struct{ NotFoundErrorJSONResponse } -func (response WriteFile404JSONResponse) VisitWriteFileResponse(w http.ResponseWriter) error { +func (response ProcessStdoutStream404JSONResponse) VisitProcessStdoutStreamResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(404) return json.NewEncoder(w).Encode(response) } -type WriteFile500JSONResponse struct{ InternalErrorJSONResponse } +type ProcessStdoutStream500JSONResponse struct{ InternalErrorJSONResponse } -func (response WriteFile500JSONResponse) VisitWriteFileResponse(w http.ResponseWriter) error { +func (response ProcessStdoutStream500JSONResponse) VisitProcessStdoutStreamResponse(w http.ResponseWriter) error { w.Header().Set("Content-Type", "application/json") w.WriteHeader(500) @@ -5247,6 +7137,27 @@ type StrictServerInterface interface { // Write or create a file // (PUT /fs/write_file) WriteFile(ctx context.Context, request WriteFileRequestObject) (WriteFileResponseObject, error) + // Subscribe to logs via SSE + // (GET /logs/stream) + LogsStream(ctx context.Context, request LogsStreamRequestObject) (LogsStreamResponseObject, error) + // Execute a command synchronously (optional streaming) + // (POST /process/exec) + ProcessExec(ctx context.Context, request ProcessExecRequestObject) (ProcessExecResponseObject, error) + // Execute a command asynchronously + // (POST /process/spawn) + ProcessSpawn(ctx context.Context, request ProcessSpawnRequestObject) (ProcessSpawnResponseObject, error) + // Send signal to process + // (POST /process/{process_id}/kill) + ProcessKill(ctx context.Context, request ProcessKillRequestObject) (ProcessKillResponseObject, error) + // Get process status + // (GET /process/{process_id}/status) + ProcessStatus(ctx context.Context, request ProcessStatusRequestObject) (ProcessStatusResponseObject, error) + // Write to process stdin + // (POST /process/{process_id}/stdin) + ProcessStdin(ctx context.Context, request ProcessStdinRequestObject) (ProcessStdinResponseObject, error) + // Stream process stdout/stderr (SSE) + // (GET /process/{process_id}/stdout/stream) + ProcessStdoutStream(ctx context.Context, request ProcessStdoutStreamRequestObject) (ProcessStdoutStreamResponseObject, error) // Delete a previously recorded video file // (POST /recording/delete) DeleteRecording(ctx context.Context, request DeleteRecordingRequestObject) (DeleteRecordingResponseObject, error) @@ -5761,6 +7672,212 @@ func (sh *strictHandler) WriteFile(w http.ResponseWriter, r *http.Request, param } } +// LogsStream operation middleware +func (sh *strictHandler) LogsStream(w http.ResponseWriter, r *http.Request, params LogsStreamParams) { + var request LogsStreamRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.LogsStream(ctx, request.(LogsStreamRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "LogsStream") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(LogsStreamResponseObject); ok { + if err := validResponse.VisitLogsStreamResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ProcessExec operation middleware +func (sh *strictHandler) ProcessExec(w http.ResponseWriter, r *http.Request) { + var request ProcessExecRequestObject + + var body ProcessExecJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ProcessExec(ctx, request.(ProcessExecRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ProcessExec") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ProcessExecResponseObject); ok { + if err := validResponse.VisitProcessExecResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ProcessSpawn operation middleware +func (sh *strictHandler) ProcessSpawn(w http.ResponseWriter, r *http.Request) { + var request ProcessSpawnRequestObject + + var body ProcessSpawnJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ProcessSpawn(ctx, request.(ProcessSpawnRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ProcessSpawn") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ProcessSpawnResponseObject); ok { + if err := validResponse.VisitProcessSpawnResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ProcessKill operation middleware +func (sh *strictHandler) ProcessKill(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) { + var request ProcessKillRequestObject + + request.ProcessId = processId + + var body ProcessKillJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ProcessKill(ctx, request.(ProcessKillRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ProcessKill") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ProcessKillResponseObject); ok { + if err := validResponse.VisitProcessKillResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ProcessStatus operation middleware +func (sh *strictHandler) ProcessStatus(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) { + var request ProcessStatusRequestObject + + request.ProcessId = processId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ProcessStatus(ctx, request.(ProcessStatusRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ProcessStatus") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ProcessStatusResponseObject); ok { + if err := validResponse.VisitProcessStatusResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ProcessStdin operation middleware +func (sh *strictHandler) ProcessStdin(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) { + var request ProcessStdinRequestObject + + request.ProcessId = processId + + var body ProcessStdinJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode JSON body: %w", err)) + return + } + request.Body = &body + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ProcessStdin(ctx, request.(ProcessStdinRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ProcessStdin") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ProcessStdinResponseObject); ok { + if err := validResponse.VisitProcessStdinResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + +// ProcessStdoutStream operation middleware +func (sh *strictHandler) ProcessStdoutStream(w http.ResponseWriter, r *http.Request, processId openapi_types.UUID) { + var request ProcessStdoutStreamRequestObject + + request.ProcessId = processId + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ProcessStdoutStream(ctx, request.(ProcessStdoutStreamRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ProcessStdoutStream") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ProcessStdoutStreamResponseObject); ok { + if err := validResponse.VisitProcessStdoutStreamResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // DeleteRecording operation middleware func (sh *strictHandler) DeleteRecording(w http.ResponseWriter, r *http.Request) { var request DeleteRecordingRequestObject @@ -5907,57 +8024,80 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index f4b5bb08..199bf166 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -23,6 +23,162 @@ paths: $ref: "#/components/responses/ConflictError" "500": $ref: "#/components/responses/InternalError" + /process/exec: + post: + summary: Execute a command synchronously (optional streaming) + operationId: processExec + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessExecRequest" + responses: + "200": + description: Execution result + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessExecResult" + "400": + $ref: "#/components/responses/BadRequestError" + "500": + $ref: "#/components/responses/InternalError" + /process/spawn: + post: + summary: Execute a command asynchronously + operationId: processSpawn + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessSpawnRequest" + responses: + "200": + description: Spawned + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessSpawnResult" + "400": + $ref: "#/components/responses/BadRequestError" + "500": + $ref: "#/components/responses/InternalError" + /process/{process_id}/status: + get: + summary: Get process status + operationId: processStatus + parameters: + - name: process_id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: Status + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessStatus" + "400": + $ref: "#/components/responses/BadRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + /process/{process_id}/stdout/stream: + get: + summary: Stream process stdout/stderr (SSE) + operationId: processStdoutStream + parameters: + - name: process_id + in: path + required: true + schema: + type: string + format: uuid + responses: + "200": + description: SSE stream of process output and lifecycle events + headers: + X-SSE-Content-Type: + description: Media type of SSE data events (application/json) + schema: + type: string + const: application/json + content: + text/event-stream: + schema: + $ref: "#/components/schemas/ProcessStreamEvent" + "400": + $ref: "#/components/responses/BadRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + /process/{process_id}/stdin: + post: + summary: Write to process stdin + operationId: processStdin + parameters: + - name: process_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessStdinRequest" + responses: + "200": + description: Bytes written + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessStdinResult" + "400": + $ref: "#/components/responses/BadRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + /process/{process_id}/kill: + post: + summary: Send signal to process + operationId: processKill + parameters: + - name: process_id + in: path + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/ProcessKillRequest" + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/OkResponse" + "400": + $ref: "#/components/responses/BadRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" /recording/stop: post: summary: Stop the recording @@ -154,6 +310,48 @@ paths: $ref: "#/components/responses/BadRequestError" "500": $ref: "#/components/responses/InternalError" + /logs/stream: + get: + summary: Subscribe to logs via SSE + operationId: logsStream + parameters: + - in: query + name: source + required: true + schema: + type: string + enum: [path, supervisor] + - in: query + name: follow + required: false + schema: + type: boolean + default: true + - in: query + description: only required if source is path + name: path + required: false + schema: + type: string + - in: query + name: supervisor_process + description: only required if source is supervisor + required: false + schema: + type: string + responses: + "200": + description: SSE stream of logs + headers: + X-SSE-Content-Type: + description: Media type of SSE data events (application/json) + schema: + type: string + const: application/json + content: + text/event-stream: + schema: + $ref: "#/components/schemas/LogEvent" # File system operations /fs/read_file: get: @@ -745,6 +943,178 @@ components: description: Absolute destination path. pattern: "^/.*" additionalProperties: false + ProcessExecRequest: + type: object + description: Request to execute a command synchronously. + required: [command] + properties: + command: + type: string + description: Executable or shell command to run. + args: + type: array + description: Command arguments. + items: + type: string + default: [] + cwd: + type: string + description: Working directory (absolute path) to run the command in. + nullable: true + pattern: "^/.*" + env: + type: object + description: Environment variables to set for the process. + additionalProperties: + type: string + default: {} + as_user: + type: string + description: Run the process as this user. + nullable: true + as_root: + type: boolean + description: Run the process with root privileges. + default: false + timeout_sec: + type: integer + description: Maximum execution time in seconds. + nullable: true + stream: + type: boolean + description: If true, stream output via SSE instead of returning complete buffers. + default: false + additionalProperties: false + ProcessExecResult: + type: object + description: Result of a synchronous command execution. + properties: + exit_code: + type: integer + description: Process exit code. + stdout_b64: + type: string + description: Base64-encoded stdout buffer. + stderr_b64: + type: string + description: Base64-encoded stderr buffer. + duration_ms: + type: integer + description: Execution duration in milliseconds. + additionalProperties: false + ProcessSpawnRequest: + allOf: + - $ref: "#/components/schemas/ProcessExecRequest" + - type: object + properties: + stream: + type: boolean + readOnly: true + description: Streaming is handled via the stdout/stream endpoint for spawned processes. + ProcessSpawnResult: + type: object + description: Information about a spawned process. + properties: + process_id: + type: string + format: uuid + description: Server-assigned identifier for the process. + pid: + type: integer + description: OS process ID. + started_at: + type: string + format: date-time + description: Timestamp when the process started. + additionalProperties: false + ProcessStatus: + type: object + description: Current status of a process. + properties: + state: + type: string + enum: [running, exited] + description: Process state. + exit_code: + type: integer + nullable: true + description: Exit code if the process has exited. + cpu_pct: + type: number + description: Estimated CPU usage percentage. + mem_bytes: + type: integer + description: Estimated resident memory usage in bytes. + additionalProperties: false + ProcessStdinRequest: + type: object + description: Data to write to the process standard input. + required: [data_b64] + properties: + data_b64: + type: string + description: Base64-encoded data to write. + additionalProperties: false + ProcessStdinResult: + type: object + description: Result of writing to stdin. + properties: + written_bytes: + type: integer + description: Number of bytes written. + additionalProperties: false + ProcessKillRequest: + type: object + description: Signal to send to the process. + required: [signal] + properties: + signal: + type: string + enum: [TERM, KILL, INT, HUP] + default: TERM + description: Signal to send. + additionalProperties: false + ProcessStreamEvent: + type: object + description: SSE payload representing process output or lifecycle events. + properties: + stream: + type: string + description: Source stream of the data chunk. + enum: [stdout, stderr] + data_b64: + type: string + description: Base64-encoded data from the process stream. + event: + type: string + description: Lifecycle event type. + enum: [exit] + exit_code: + type: integer + description: Exit code when the event is "exit". + additionalProperties: false + LogEvent: + type: object + description: A log entry from the application. + required: [message, timestamp] + properties: + timestamp: + type: string + format: date-time + description: Time the log entry was produced. + message: + type: string + description: Log message text. + OkResponse: + type: object + description: Generic OK response. + required: [ok] + properties: + ok: + type: boolean + description: Indicates success. + default: true + additionalProperties: false responses: BadRequestError: description: Bad Request From 25df2ffb9b5aa1b2a3c824f2515e2c56450231e9 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 21:21:56 +0000 Subject: [PATCH 11/24] rm ncat proxy --- .github/workflows/server-test.yaml | 3 + images/chromium-headful/Dockerfile | 4 +- .../supervisor/services/ncat.conf | 10 - images/chromium-headful/wrapper.sh | 14 +- images/chromium-headless/image/Dockerfile | 2 +- .../image/supervisor/services/ncat.conf | 10 - images/chromium-headless/image/wrapper.sh | 14 +- server/cmd/api/main.go | 44 ++++ server/go.mod | 1 + server/go.sum | 2 + server/lib/devtoolsproxy/proxy.go | 226 ++++++++++++++++++ server/lib/devtoolsproxy/proxy_test.go | 220 +++++++++++++++++ 12 files changed, 502 insertions(+), 48 deletions(-) delete mode 100644 images/chromium-headful/supervisor/services/ncat.conf delete mode 100644 images/chromium-headless/image/supervisor/services/ncat.conf create mode 100644 server/lib/devtoolsproxy/proxy.go create mode 100644 server/lib/devtoolsproxy/proxy_test.go diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml index 0c02e7c7..90300cd2 100644 --- a/.github/workflows/server-test.yaml +++ b/.github/workflows/server-test.yaml @@ -20,6 +20,9 @@ jobs: go-version-file: "server/go.mod" cache: true + - name: Install Chromium + run: sudo apt-get update && (sudo apt-get install -y chromium || sudo apt-get install -y chromium-browser) + - name: Run server Makefile tests run: make test working-directory: server diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 4aec10ac..340c3902 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -136,9 +136,9 @@ RUN set -eux; \ apt-get clean -y; \ rm -rf /var/lib/apt/lists/* /var/cache/apt/ -# install chromium & ncat for proxying the remote debugging port +# install chromium RUN add-apt-repository -y ppa:xtradeb/apps -RUN apt update -y && apt install -y chromium ncat +RUN apt update -y && apt install -y chromium # setup desktop env & app ENV DISPLAY_NUM=1 diff --git a/images/chromium-headful/supervisor/services/ncat.conf b/images/chromium-headful/supervisor/services/ncat.conf deleted file mode 100644 index 7d2b7855..00000000 --- a/images/chromium-headful/supervisor/services/ncat.conf +++ /dev/null @@ -1,10 +0,0 @@ -[program:ncat] -; Use env var expansion by launching under bash -lc so $CHROME_PORT and $INTERNAL_PORT resolve -command=/bin/bash -lc 'ncat --sh-exec "ncat 0.0.0.0 ${INTERNAL_PORT:-9223}" -l "${CHROME_PORT:-9222}" --keep-open' -autostart=false -autorestart=true -startsecs=2 -stdout_logfile=/var/log/supervisord/ncat -redirect_stderr=true - - diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index b2f60193..f7d691db 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -95,7 +95,7 @@ start_dynamic_log_aggregator export DISPLAY=:1 -# Predefine ports and export so supervisord programs (e.g., ncat) can read them +# Predefine ports and export for services export INTERNAL_PORT="${INTERNAL_PORT:-9223}" export CHROME_PORT="${CHROME_PORT:-9222}" @@ -109,7 +109,6 @@ cleanup () { enable_scale_to_zero supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true supervisorctl -c /etc/supervisor/supervisord.conf stop kernel-images-api || true - supervisorctl -c /etc/supervisor/supervisord.conf stop ncat || true supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true # Stop log tailers if [[ -n "${tail_pids[*]:-}" ]]; then @@ -171,7 +170,6 @@ done export DBUS_SESSION_BUS_ADDRESS="unix:path=/run/dbus/system_bus_socket" # Start Chromium with display :1 and remote debugging, loading our recorder extension. -# Use ncat to listen on 0.0.0.0:9222 since chromium does not let you listen on 0.0.0.0 anymore: https://github.com/pyppeteer/pyppeteer/pull/379#issuecomment-217029626 echo "[wrapper] Starting Chromium via supervisord on internal port $INTERNAL_PORT" supervisorctl -c /etc/supervisor/supervisord.conf start chromium echo "[wrapper] Waiting for Chromium remote debugging on 127.0.0.1:$INTERNAL_PORT..." @@ -182,16 +180,6 @@ for i in {1..100}; do sleep 0.2 done -echo "[wrapper] Starting ncat proxy via supervisord on port $CHROME_PORT" -supervisorctl -c /etc/supervisor/supervisord.conf start ncat -echo "[wrapper] Waiting for ncat to listen on 127.0.0.1:$CHROME_PORT..." -for i in {1..50}; do - if nc -z 127.0.0.1 "$CHROME_PORT" 2>/dev/null; then - break - fi - sleep 0.2 -done - if [[ "${ENABLE_WEBRTC:-}" == "true" ]]; then # use webrtc echo "[wrapper] ✨ Starting neko (webrtc server) via supervisord." diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index 3d3240cf..6c42cb8e 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -33,7 +33,7 @@ RUN set -xe; \ supervisor; RUN add-apt-repository -y ppa:xtradeb/apps -RUN apt update -y && apt install -y chromium ncat +RUN apt update -y && apt install -y chromium # Install FFmpeg (latest static build) for the recording server RUN set -eux; \ diff --git a/images/chromium-headless/image/supervisor/services/ncat.conf b/images/chromium-headless/image/supervisor/services/ncat.conf deleted file mode 100644 index 7d2b7855..00000000 --- a/images/chromium-headless/image/supervisor/services/ncat.conf +++ /dev/null @@ -1,10 +0,0 @@ -[program:ncat] -; Use env var expansion by launching under bash -lc so $CHROME_PORT and $INTERNAL_PORT resolve -command=/bin/bash -lc 'ncat --sh-exec "ncat 0.0.0.0 ${INTERNAL_PORT:-9223}" -l "${CHROME_PORT:-9222}" --keep-open' -autostart=false -autorestart=true -startsecs=2 -stdout_logfile=/var/log/supervisord/ncat -redirect_stderr=true - - diff --git a/images/chromium-headless/image/wrapper.sh b/images/chromium-headless/image/wrapper.sh index c43027e8..72dee2df 100755 --- a/images/chromium-headless/image/wrapper.sh +++ b/images/chromium-headless/image/wrapper.sh @@ -126,7 +126,6 @@ export CHROME_PORT="${CHROME_PORT:-9222}" cleanup () { echo "[wrapper] Cleaning up..." supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true - supervisorctl -c /etc/supervisor/supervisord.conf stop ncat || true supervisorctl -c /etc/supervisor/supervisord.conf stop xvfb || true supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true supervisorctl -c /etc/supervisor/supervisord.conf stop kernel-images-api || true @@ -171,16 +170,7 @@ done echo "[wrapper] Starting Chromium via supervisord on internal port $INTERNAL_PORT" supervisorctl -c /etc/supervisor/supervisord.conf start chromium for i in {1..100}; do - if ncat -z 127.0.0.1 "$INTERNAL_PORT" 2>/dev/null; then - break - fi - sleep 0.2 -done - -echo "[wrapper] Starting ncat proxy via supervisord on port $CHROME_PORT" -supervisorctl -c /etc/supervisor/supervisord.conf start ncat -for i in {1..50}; do - if ncat -z 127.0.0.1 "$CHROME_PORT" 2>/dev/null; then + if (echo >/dev/tcp/127.0.0.1/"$INTERNAL_PORT") >/dev/null 2>&1; then break fi sleep 0.2 @@ -191,7 +181,7 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then supervisorctl -c /etc/supervisor/supervisord.conf start kernel-images-api API_PORT="${KERNEL_IMAGES_API_PORT:-10001}" echo "[wrapper] Waiting for kernel-images API on 127.0.0.1:${API_PORT}..." - while ! ncat -z 127.0.0.1 "${API_PORT}" 2>/dev/null; do + while ! (echo >/dev/tcp/127.0.0.1/"${API_PORT}") >/dev/null 2>&1; do sleep 0.5 done fi diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index 50a56a14..cae2a278 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -19,6 +19,7 @@ import ( serverpkg "github.com/onkernel/kernel-images/server" "github.com/onkernel/kernel-images/server/cmd/api/api" "github.com/onkernel/kernel-images/server/cmd/config" + "github.com/onkernel/kernel-images/server/lib/devtoolsproxy" "github.com/onkernel/kernel-images/server/lib/logger" oapi "github.com/onkernel/kernel-images/server/lib/oapi" "github.com/onkernel/kernel-images/server/lib/recorder" @@ -100,6 +101,37 @@ func main() { Handler: r, } + // DevTools WebSocket proxy setup: tail Chromium supervisord log and start WS server on :9222 only after upstream is found + const chromiumLogPath = "/var/log/supervisord/chromium" + upstreamMgr := devtoolsproxy.NewUpstreamManager(chromiumLogPath, slogger) + upstreamMgr.Start(ctx) + + // wait up to 10 seconds for initial upstream; exit nonzero if not found + if _, err := upstreamMgr.WaitForInitial(10 * time.Second); err != nil { + slogger.Error("devtools upstream not available", "err", err) + os.Exit(1) + } + + rDevtools := chi.NewRouter() + rDevtools.Use( + chiMiddleware.Logger, + chiMiddleware.Recoverer, + func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctxWithLogger := logger.AddToContext(r.Context(), slogger) + next.ServeHTTP(w, r.WithContext(ctxWithLogger)) + }) + }, + ) + rDevtools.Get("/*", func(w http.ResponseWriter, r *http.Request) { + devtoolsproxy.WebSocketProxyHandler(upstreamMgr, slogger).ServeHTTP(w, r) + }) + + srvDevtools := &http.Server{ + Addr: "0.0.0.0:9222", + Handler: rDevtools, + } + go func() { slogger.Info("http server starting", "addr", srv.Addr) if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { @@ -108,6 +140,14 @@ func main() { } }() + go func() { + slogger.Info("devtools websocket proxy starting", "addr", srvDevtools.Addr) + if err := srvDevtools.ListenAndServe(); err != nil && err != http.ErrServerClosed { + slogger.Error("devtools websocket proxy failed", "err", err) + stop() + } + }() + // graceful shutdown <-ctx.Done() slogger.Info("shutdown signal received") @@ -122,6 +162,10 @@ func main() { g.Go(func() error { return apiService.Shutdown(shutdownCtx) }) + g.Go(func() error { + upstreamMgr.Stop() + return srvDevtools.Shutdown(shutdownCtx) + }) if err := g.Wait(); err != nil { slogger.Error("server failed to shutdown", "err", err) diff --git a/server/go.mod b/server/go.mod index 036553b8..fda78cf6 100644 --- a/server/go.mod +++ b/server/go.mod @@ -20,6 +20,7 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/gorilla/websocket v1.5.3 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect diff --git a/server/go.sum b/server/go.sum index 40bb015b..f69e6f11 100644 --- a/server/go.sum +++ b/server/go.sum @@ -21,6 +21,8 @@ github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= diff --git a/server/lib/devtoolsproxy/proxy.go b/server/lib/devtoolsproxy/proxy.go new file mode 100644 index 00000000..9eaba416 --- /dev/null +++ b/server/lib/devtoolsproxy/proxy.go @@ -0,0 +1,226 @@ +package devtoolsproxy + +import ( + "bufio" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "os/exec" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/gorilla/websocket" +) + +var devtoolsListeningRegexp = regexp.MustCompile(`DevTools listening on (ws://\S+)`) + +// UpstreamManager tails the Chromium supervisord log and extracts the current DevTools +// websocket URL, updating it whenever Chromium restarts and emits a new line. +type UpstreamManager struct { + logFilePath string + logger *slog.Logger + + currentURL atomic.Value // string + + startOnce sync.Once + stopOnce sync.Once + cancelTail context.CancelFunc +} + +func NewUpstreamManager(logFilePath string, logger *slog.Logger) *UpstreamManager { + um := &UpstreamManager{logFilePath: logFilePath, logger: logger} + um.currentURL.Store("") + return um +} + +// Start begins background tailing and updating the upstream URL until ctx is done. +func (u *UpstreamManager) Start(ctx context.Context) { + u.startOnce.Do(func() { + ctx, cancel := context.WithCancel(ctx) + u.cancelTail = cancel + go u.tailLoop(ctx) + }) +} + +// Stop cancels the background tailer. +func (u *UpstreamManager) Stop() { + u.stopOnce.Do(func() { + if u.cancelTail != nil { + u.cancelTail() + } + }) +} + +// WaitForInitial blocks until an initial upstream URL has been discovered or the timeout elapses. +func (u *UpstreamManager) WaitForInitial(timeout time.Duration) (string, error) { + deadline := time.Now().Add(timeout) + for { + if url := u.Current(); url != "" { + return url, nil + } + if time.Now().After(deadline) { + return "", fmt.Errorf("devtools upstream not found within %s", timeout) + } + time.Sleep(100 * time.Millisecond) + } +} + +// Current returns the current upstream websocket URL if known, or empty string. +func (u *UpstreamManager) Current() string { + val, _ := u.currentURL.Load().(string) + return val +} + +func (u *UpstreamManager) setCurrent(url string) { + prev := u.Current() + if url != "" && url != prev { + u.logger.Info("devtools upstream updated", slog.String("url", url)) + u.currentURL.Store(url) + } +} + +func (u *UpstreamManager) tailLoop(ctx context.Context) { + backoff := 250 * time.Millisecond + for { + if ctx.Err() != nil { + return + } + // Run one tail session. If it exits, retry with a small backoff. + u.runTailOnce(ctx) + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + } + // cap backoff to 2s + if backoff < 2*time.Second { + backoff *= 2 + } + } +} + +func (u *UpstreamManager) runTailOnce(ctx context.Context) { + cmd := exec.CommandContext(ctx, "tail", "-f", "-n", "+1", u.logFilePath) + stdout, err := cmd.StdoutPipe() + if err != nil { + u.logger.Error("failed to open tail stdout", slog.String("err", err.Error())) + return + } + if err := cmd.Start(); err != nil { + // Common when file does not exist yet; log at debug level + if strings.Contains(err.Error(), "No such file or directory") { + u.logger.Debug("supervisord log not found yet; will retry", slog.String("path", u.logFilePath)) + } else { + u.logger.Error("failed to start tail", slog.String("err", err.Error())) + } + return + } + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + + scanner := bufio.NewScanner(stdout) + for scanner.Scan() { + select { + case <-ctx.Done(): + return + default: + } + line := scanner.Text() + if matches := devtoolsListeningRegexp.FindStringSubmatch(line); len(matches) == 2 { + u.setCurrent(matches[1]) + } + } + if err := scanner.Err(); err != nil && !errors.Is(err, context.Canceled) { + u.logger.Error("tail scanner error", slog.String("err", err.Error())) + } +} + +// WebSocketProxyHandler returns an http.Handler that upgrades incoming connections and +// proxies them to the current upstream websocket URL. It expects only websocket requests. +func WebSocketProxyHandler(mgr *UpstreamManager, logger *slog.Logger) http.Handler { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upstreamCurrent := mgr.Current() + if upstreamCurrent == "" { + http.Error(w, "upstream not ready", http.StatusServiceUnavailable) + return + } + parsed, err := url.Parse(upstreamCurrent) + if err != nil { + http.Error(w, "invalid upstream", http.StatusInternalServerError) + return + } + // Always use the full upstream path and query, ignoring the client's request path/query + upstreamURL := (&url.URL{Scheme: parsed.Scheme, Host: parsed.Host, Path: parsed.Path, RawQuery: parsed.RawQuery}).String() + clientConn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + logger.Error("websocket upgrade failed", slog.String("err", err.Error())) + return + } + upstreamConn, _, err := websocket.DefaultDialer.Dial(upstreamURL, nil) + if err != nil { + logger.Error("dial upstream failed", slog.String("err", err.Error()), slog.String("url", upstreamURL)) + _ = clientConn.Close() + return + } + logger.Debug("proxying devtools websocket", slog.String("url", upstreamURL)) + + var once sync.Once + cleanup := func() { + once.Do(func() { + _ = upstreamConn.Close() + _ = clientConn.Close() + }) + } + proxyWebSocket(r.Context(), clientConn, upstreamConn, cleanup) + }) +} + +type wsConn interface { + ReadMessage() (messageType int, p []byte, err error) + WriteMessage(messageType int, data []byte) error + Close() error +} + +func proxyWebSocket(ctx context.Context, clientConn, upstreamConn wsConn, onClose func()) { + errChan := make(chan error, 2) + go func() { + for { + mt, msg, err := clientConn.ReadMessage() + if err != nil { + errChan <- err + break + } + if err := upstreamConn.WriteMessage(mt, msg); err != nil { + errChan <- err + break + } + } + }() + go func() { + for { + mt, msg, err := upstreamConn.ReadMessage() + if err != nil { + errChan <- err + break + } + if err := clientConn.WriteMessage(mt, msg); err != nil { + errChan <- err + break + } + } + }() + select { + case <-ctx.Done(): + case <-errChan: + } + onClose() +} diff --git a/server/lib/devtoolsproxy/proxy_test.go b/server/lib/devtoolsproxy/proxy_test.go new file mode 100644 index 00000000..93199a78 --- /dev/null +++ b/server/lib/devtoolsproxy/proxy_test.go @@ -0,0 +1,220 @@ +package devtoolsproxy + +import ( + "context" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gorilla/websocket" +) + +func silentLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +func findBrowserBinary() (string, error) { + candidates := []string{"chromium", "chromium-browser", "google-chrome", "google-chrome-stable"} + for _, name := range candidates { + if p, err := exec.LookPath(name); err == nil { + return p, nil + } + } + return "", fmt.Errorf("no chromium/chrome binary found") +} + +func getFreePort() (int, error) { + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, err + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port, nil +} + +func waitForCondition(timeout time.Duration, cond func() bool) bool { + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if cond() { + return true + } + time.Sleep(50 * time.Millisecond) + } + return cond() +} + +func TestWaitForInitialTimeoutWhenLogMissing(t *testing.T) { + mgr := NewUpstreamManager("/tmp/not-a-real-file-hopefully", silentLogger()) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + mgr.Start(ctx) + if _, err := mgr.WaitForInitial(300 * time.Millisecond); err == nil { + t.Fatalf("expected timeout error, got nil") + } +} + +func TestWebSocketProxyHandler_ProxiesEcho(t *testing.T) { + // Start an echo websocket server as upstream + echoSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + upgrader := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }} + c, err := upgrader.Upgrade(w, r, nil) + if err != nil { + t.Fatalf("upgrade failed: %v", err) + return + } + defer c.Close() + for { + mt, msg, err := c.ReadMessage() + if err != nil { + return + } + // echo back with path+query prefixed to verify preservation + payload := []byte(r.URL.Path + "?" + r.URL.RawQuery + "|" + string(msg)) + if err := c.WriteMessage(mt, payload); err != nil { + return + } + } + })) + defer echoSrv.Close() + + // Build ws URL for upstream + u, _ := url.Parse(echoSrv.URL) + u.Scheme = "ws" + u.Path = "/echo" + u.RawQuery = "k=v" + + logger := silentLogger() + mgr := NewUpstreamManager("/dev/null", logger) + // seed current upstream to echo server (bypass tailing) + mgr.setCurrent((&url.URL{Scheme: u.Scheme, Host: u.Host}).String()) + + proxy := WebSocketProxyHandler(mgr, logger) + proxySrv := httptest.NewServer(proxy) + defer proxySrv.Close() + + // Connect to proxy with the same path/query and verify echo + pu, _ := url.Parse(proxySrv.URL) + pu.Scheme = "ws" + pu.Path = u.Path + pu.RawQuery = u.RawQuery + + conn, _, err := websocket.DefaultDialer.Dial(pu.String(), nil) + if err != nil { + t.Fatalf("dial proxy failed: %v", err) + } + defer conn.Close() + msg := "hello" + if err := conn.WriteMessage(websocket.TextMessage, []byte(msg)); err != nil { + t.Fatalf("write failed: %v", err) + } + _, resp, err := conn.ReadMessage() + if err != nil { + t.Fatalf("read failed: %v", err) + } + expectedPrefix := u.Path + "?" + u.RawQuery + "|" + if !strings.HasPrefix(string(resp), expectedPrefix) || !strings.HasSuffix(string(resp), msg) { + t.Fatalf("unexpected echo: %q", string(resp)) + } +} + +func TestUpstreamManagerDetectsChromiumAndRestart(t *testing.T) { + browser, err := findBrowserBinary() + if err != nil { + t.Skip("chromium/chrome not installed in environment") + } + + logDir := t.TempDir() + logPath := filepath.Join(logDir, "chromium.log") + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + t.Fatalf("open log file: %v", err) + } + defer logFile.Close() + + logger := silentLogger() + mgr := NewUpstreamManager(logPath, logger) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + mgr.Start(ctx) + + startChromium := func(port int) (*exec.Cmd, error) { + userDir := t.TempDir() + args := []string{ + "--headless=new", + "--remote-debugging-address=127.0.0.1", + fmt.Sprintf("--remote-debugging-port=%d", port), + "--no-first-run", + "--no-default-browser-check", + "--disable-gpu", + "--disable-software-rasterizer", + "--disable-dev-shm-usage", + fmt.Sprintf("--user-data-dir=%s", userDir), + "about:blank", + } + cmd := exec.Command(browser, args...) + cmd.Stdout = logFile + cmd.Stderr = logFile + if err := cmd.Start(); err != nil { + return nil, err + } + return cmd, nil + } + + port1, err := getFreePort() + if err != nil { + t.Fatalf("get free port: %v", err) + } + cmd1, err := startChromium(port1) + if err != nil { + t.Fatalf("start chromium 1: %v", err) + } + defer func() { + _ = cmd1.Process.Kill() + _, _ = cmd1.Process.Wait() + }() + + // Wait for initial upstream containing port1 + ok := waitForCondition(20*time.Second, func() bool { + u := mgr.Current() + return strings.Contains(u, fmt.Sprintf(":%d/", port1)) + }) + if !ok { + t.Fatalf("did not detect initial upstream for port %d; got: %q", port1, mgr.Current()) + } + + // Restart on a new port + port2, err := getFreePort() + if err != nil { + t.Fatalf("get free port 2: %v", err) + } + _ = cmd1.Process.Kill() + _, _ = cmd1.Process.Wait() + + cmd2, err := startChromium(port2) + if err != nil { + t.Fatalf("start chromium 2: %v", err) + } + defer func() { + _ = cmd2.Process.Kill() + _, _ = cmd2.Process.Wait() + }() + + // Expect manager to update to new port + ok = waitForCondition(20*time.Second, func() bool { + u := mgr.Current() + return strings.Contains(u, fmt.Sprintf(":%d/", port2)) + }) + if !ok { + t.Fatalf("did not update upstream to port %d; got: %q", port2, mgr.Current()) + } +} From a82572ad9fc0573e29e3ade86dab92acca048266 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 21:43:25 +0000 Subject: [PATCH 12/24] endpoint to download directory as zip --- server/cmd/api/api/fs.go | 30 ++ server/cmd/api/api/fs_test.go | 100 ++++++ server/go.mod | 2 +- server/lib/devtoolsproxy/proxy_test.go | 9 +- server/lib/oapi/oapi.go | 421 ++++++++++++++++++++----- server/openapi.yaml | 28 ++ 6 files changed, 511 insertions(+), 79 deletions(-) diff --git a/server/cmd/api/api/fs.go b/server/cmd/api/api/fs.go index 3e580585..3d0ec67e 100644 --- a/server/cmd/api/api/fs.go +++ b/server/cmd/api/api/fs.go @@ -809,3 +809,33 @@ func (s *ApiService) UploadFiles(ctx context.Context, request oapi.UploadFilesRe return oapi.UploadFiles201Response{}, nil } + +func (s *ApiService) DownloadDirZip(ctx context.Context, request oapi.DownloadDirZipRequestObject) (oapi.DownloadDirZipResponseObject, error) { + log := logger.FromContext(ctx) + path := request.Params.Path + if path == "" { + return oapi.DownloadDirZip400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "path cannot be empty"}}, nil + } + + info, err := os.Stat(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return oapi.DownloadDirZip404JSONResponse{NotFoundErrorJSONResponse: oapi.NotFoundErrorJSONResponse{Message: "directory not found"}}, nil + } + log.Error("failed to stat path", "err", err, "path", path) + return oapi.DownloadDirZip500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to stat path"}}, nil + } + if !info.IsDir() { + return oapi.DownloadDirZip400JSONResponse{BadRequestErrorJSONResponse: oapi.BadRequestErrorJSONResponse{Message: "path is not a directory"}}, nil + } + + // Build zip in-memory to provide a single streaming response + zipBytes, err := ziputil.ZipDir(path) + if err != nil { + log.Error("failed to create zip archive", "err", err, "path", path) + return oapi.DownloadDirZip500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create zip"}}, nil + } + + body := io.NopCloser(bytes.NewReader(zipBytes)) + return oapi.DownloadDirZip200ApplicationzipResponse{Body: body, ContentLength: int64(len(zipBytes))}, nil +} diff --git a/server/cmd/api/api/fs_test.go b/server/cmd/api/api/fs_test.go index 34daa1ec..f8ddee44 100644 --- a/server/cmd/api/api/fs_test.go +++ b/server/cmd/api/api/fs_test.go @@ -463,3 +463,103 @@ func TestUploadZipValidationErrors(t *testing.T) { t.Fatalf("expected 400 for missing zip_file, got %T", resp2) } } + +func TestDownloadDirZipSuccess(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{} + + // Prepare a directory with nested content + root := t.TempDir() + nested := filepath.Join(root, "dir", "sub") + if err := os.MkdirAll(nested, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + f1 := filepath.Join(root, "top.txt") + f2 := filepath.Join(nested, "a.txt") + if err := os.WriteFile(f1, []byte("top"), 0o644); err != nil { + t.Fatalf("write top: %v", err) + } + if err := os.WriteFile(f2, []byte("hello"), 0o644); err != nil { + t.Fatalf("write nested: %v", err) + } + + resp, err := svc.DownloadDirZip(ctx, oapi.DownloadDirZipRequestObject{Params: oapi.DownloadDirZipParams{Path: root}}) + if err != nil { + t.Fatalf("DownloadDirZip error: %v", err) + } + r200, ok := resp.(oapi.DownloadDirZip200ApplicationzipResponse) + if !ok { + t.Fatalf("unexpected response type: %T", resp) + } + data, err := io.ReadAll(r200.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + + zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatalf("open zip: %v", err) + } + + // Expect both files present with paths relative to root + want := map[string]string{ + "top.txt": "top", + "dir/sub/a.txt": "hello", + } + found := map[string]bool{} + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + if _, ok := want[f.Name]; ok { + rc, err := f.Open() + if err != nil { + t.Fatalf("open zip entry: %v", err) + } + b, _ := io.ReadAll(rc) + rc.Close() + if string(b) != want[f.Name] { + t.Fatalf("content mismatch for %s: %q", f.Name, string(b)) + } + found[f.Name] = true + } + } + for k := range want { + if !found[k] { + t.Fatalf("missing zip entry: %s", k) + } + } +} + +func TestDownloadDirZipErrors(t *testing.T) { + t.Parallel() + ctx := context.Background() + svc := &ApiService{} + + // Empty path + if resp, err := svc.DownloadDirZip(ctx, oapi.DownloadDirZipRequestObject{Params: oapi.DownloadDirZipParams{Path: ""}}); err != nil { + t.Fatalf("unexpected error: %v", err) + } else if _, ok := resp.(oapi.DownloadDirZip400JSONResponse); !ok { + t.Fatalf("expected 400 for empty path, got %T", resp) + } + + // Non-existent path + missing := filepath.Join(t.TempDir(), "nope") + if resp, err := svc.DownloadDirZip(ctx, oapi.DownloadDirZipRequestObject{Params: oapi.DownloadDirZipParams{Path: missing}}); err != nil { + t.Fatalf("unexpected error: %v", err) + } else if _, ok := resp.(oapi.DownloadDirZip404JSONResponse); !ok { + t.Fatalf("expected 404 for missing dir, got %T", resp) + } + + // Path is a file, not a directory + tmp := filepath.Join(t.TempDir(), "file.txt") + if err := os.WriteFile(tmp, []byte("x"), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + if resp, err := svc.DownloadDirZip(ctx, oapi.DownloadDirZipRequestObject{Params: oapi.DownloadDirZipParams{Path: tmp}}); err != nil { + t.Fatalf("unexpected error: %v", err) + } else if _, ok := resp.(oapi.DownloadDirZip400JSONResponse); !ok { + t.Fatalf("expected 400 for file path, got %T", resp) + } +} diff --git a/server/go.mod b/server/go.mod index fda78cf6..c403a035 100644 --- a/server/go.mod +++ b/server/go.mod @@ -8,6 +8,7 @@ require ( github.com/ghodss/yaml v1.0.0 github.com/go-chi/chi/v5 v5.2.1 github.com/google/uuid v1.5.0 + github.com/gorilla/websocket v1.5.3 github.com/kelseyhightower/envconfig v1.4.0 github.com/nrednav/cuid2 v1.1.0 github.com/oapi-codegen/runtime v1.1.1 @@ -20,7 +21,6 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect diff --git a/server/lib/devtoolsproxy/proxy_test.go b/server/lib/devtoolsproxy/proxy_test.go index 93199a78..353f56a0 100644 --- a/server/lib/devtoolsproxy/proxy_test.go +++ b/server/lib/devtoolsproxy/proxy_test.go @@ -95,8 +95,8 @@ func TestWebSocketProxyHandler_ProxiesEcho(t *testing.T) { logger := silentLogger() mgr := NewUpstreamManager("/dev/null", logger) - // seed current upstream to echo server (bypass tailing) - mgr.setCurrent((&url.URL{Scheme: u.Scheme, Host: u.Host}).String()) + // seed current upstream to echo server including path/query (bypass tailing) + mgr.setCurrent((&url.URL{Scheme: u.Scheme, Host: u.Host, Path: u.Path, RawQuery: u.RawQuery}).String()) proxy := WebSocketProxyHandler(mgr, logger) proxySrv := httptest.NewServer(proxy) @@ -105,8 +105,9 @@ func TestWebSocketProxyHandler_ProxiesEcho(t *testing.T) { // Connect to proxy with the same path/query and verify echo pu, _ := url.Parse(proxySrv.URL) pu.Scheme = "ws" - pu.Path = u.Path - pu.RawQuery = u.RawQuery + // Provide a different client path/query; proxy should ignore these + pu.Path = "/client" + pu.RawQuery = "x=y" conn, _, err := websocket.DefaultDialer.Dial(pu.String(), nil) if err != nil { diff --git a/server/lib/oapi/oapi.go b/server/lib/oapi/oapi.go index 8d8d1353..2310a3ee 100644 --- a/server/lib/oapi/oapi.go +++ b/server/lib/oapi/oapi.go @@ -423,6 +423,12 @@ type InternalError = Error // NotFoundError defines model for NotFoundError. type NotFoundError = Error +// DownloadDirZipParams defines parameters for DownloadDirZip. +type DownloadDirZipParams struct { + // Path Absolute directory path to archive and download. + Path string `form:"path" json:"path"` +} + // FileInfoParams defines parameters for FileInfo. type FileInfoParams struct { // Path Absolute path of the file or directory. @@ -636,6 +642,9 @@ type ClientInterface interface { DeleteFile(ctx context.Context, body DeleteFileJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // DownloadDirZip request + DownloadDirZip(ctx context.Context, params *DownloadDirZipParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // FileInfo request FileInfo(ctx context.Context, params *FileInfoParams, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -846,6 +855,18 @@ func (c *Client) DeleteFile(ctx context.Context, body DeleteFileJSONRequestBody, return c.Client.Do(req) } +func (c *Client) DownloadDirZip(ctx context.Context, params *DownloadDirZipParams, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDownloadDirZipRequest(c.Server, params) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) FileInfo(ctx context.Context, params *FileInfoParams, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewFileInfoRequest(c.Server, params) if err != nil { @@ -1442,6 +1463,51 @@ func NewDeleteFileRequestWithBody(server string, contentType string, body io.Rea return req, nil } +// NewDownloadDirZipRequest generates requests for DownloadDirZip +func NewDownloadDirZipRequest(server string, params *DownloadDirZipParams) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/fs/download_dir_zip") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + if params != nil { + queryValues := queryURL.Query() + + if queryFrag, err := runtime.StyleParamWithLocation("form", true, "path", runtime.ParamLocationQuery, params.Path); err != nil { + return nil, err + } else if parsed, err := url.ParseQuery(queryFrag); err != nil { + return nil, err + } else { + for k, v := range parsed { + for _, v2 := range v { + queryValues.Add(k, v2) + } + } + } + + queryURL.RawQuery = queryValues.Encode() + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewFileInfoRequest generates requests for FileInfo func NewFileInfoRequest(server string, params *FileInfoParams) (*http.Request, error) { var err error @@ -2485,6 +2551,9 @@ type ClientWithResponsesInterface interface { DeleteFileWithResponse(ctx context.Context, body DeleteFileJSONRequestBody, reqEditors ...RequestEditorFn) (*DeleteFileResponse, error) + // DownloadDirZipWithResponse request + DownloadDirZipWithResponse(ctx context.Context, params *DownloadDirZipParams, reqEditors ...RequestEditorFn) (*DownloadDirZipResponse, error) + // FileInfoWithResponse request FileInfoWithResponse(ctx context.Context, params *FileInfoParams, reqEditors ...RequestEditorFn) (*FileInfoResponse, error) @@ -2692,6 +2761,30 @@ func (r DeleteFileResponse) StatusCode() int { return 0 } +type DownloadDirZipResponse struct { + Body []byte + HTTPResponse *http.Response + JSON400 *BadRequestError + JSON404 *NotFoundError + JSON500 *InternalError +} + +// Status returns HTTPResponse.Status +func (r DownloadDirZipResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DownloadDirZipResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type FileInfoResponse struct { Body []byte HTTPResponse *http.Response @@ -3333,6 +3426,15 @@ func (c *ClientWithResponses) DeleteFileWithResponse(ctx context.Context, body D return ParseDeleteFileResponse(rsp) } +// DownloadDirZipWithResponse request returning *DownloadDirZipResponse +func (c *ClientWithResponses) DownloadDirZipWithResponse(ctx context.Context, params *DownloadDirZipParams, reqEditors ...RequestEditorFn) (*DownloadDirZipResponse, error) { + rsp, err := c.DownloadDirZip(ctx, params, reqEditors...) + if err != nil { + return nil, err + } + return ParseDownloadDirZipResponse(rsp) +} + // FileInfoWithResponse request returning *FileInfoResponse func (c *ClientWithResponses) FileInfoWithResponse(ctx context.Context, params *FileInfoParams, reqEditors ...RequestEditorFn) (*FileInfoResponse, error) { rsp, err := c.FileInfo(ctx, params, reqEditors...) @@ -3799,6 +3901,46 @@ func ParseDeleteFileResponse(rsp *http.Response) (*DeleteFileResponse, error) { return response, nil } +// ParseDownloadDirZipResponse parses an HTTP response from a DownloadDirZipWithResponse call +func ParseDownloadDirZipResponse(rsp *http.Response) (*DownloadDirZipResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DownloadDirZipResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest BadRequestError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest NotFoundError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest InternalError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + // ParseFileInfoResponse parses an HTTP response from a FileInfoWithResponse call func ParseFileInfoResponse(rsp *http.Response) (*FileInfoResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) @@ -4743,6 +4885,9 @@ type ServerInterface interface { // Delete a file // (PUT /fs/delete_file) DeleteFile(w http.ResponseWriter, r *http.Request) + // Download a directory as a ZIP archive + // (GET /fs/download_dir_zip) + DownloadDirZip(w http.ResponseWriter, r *http.Request, params DownloadDirZipParams) // Get information about a file or directory // (GET /fs/file_info) FileInfo(w http.ResponseWriter, r *http.Request, params FileInfoParams) @@ -4848,6 +4993,12 @@ func (_ Unimplemented) DeleteFile(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNotImplemented) } +// Download a directory as a ZIP archive +// (GET /fs/download_dir_zip) +func (_ Unimplemented) DownloadDirZip(w http.ResponseWriter, r *http.Request, params DownloadDirZipParams) { + w.WriteHeader(http.StatusNotImplemented) +} + // Get information about a file or directory // (GET /fs/file_info) func (_ Unimplemented) FileInfo(w http.ResponseWriter, r *http.Request, params FileInfoParams) { @@ -5065,6 +5216,40 @@ func (siw *ServerInterfaceWrapper) DeleteFile(w http.ResponseWriter, r *http.Req handler.ServeHTTP(w, r) } +// DownloadDirZip operation middleware +func (siw *ServerInterfaceWrapper) DownloadDirZip(w http.ResponseWriter, r *http.Request) { + + var err error + + // Parameter object where we will unmarshal all parameters from the context + var params DownloadDirZipParams + + // ------------- Required query parameter "path" ------------- + + if paramValue := r.URL.Query().Get("path"); paramValue != "" { + + } else { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "path"}) + return + } + + err = runtime.BindQueryParameter("form", true, true, "path", r.URL.Query(), ¶ms.Path) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "path", Err: err}) + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.DownloadDirZip(w, r, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // FileInfo operation middleware func (siw *ServerInterfaceWrapper) FileInfo(w http.ResponseWriter, r *http.Request) { @@ -5726,6 +5911,9 @@ func HandlerWithOptions(si ServerInterface, options ChiServerOptions) http.Handl r.Group(func(r chi.Router) { r.Put(options.BaseURL+"/fs/delete_file", wrapper.DeleteFile) }) + r.Group(func(r chi.Router) { + r.Get(options.BaseURL+"/fs/download_dir_zip", wrapper.DownloadDirZip) + }) r.Group(func(r chi.Router) { r.Get(options.BaseURL+"/fs/file_info", wrapper.FileInfo) }) @@ -5995,6 +6183,60 @@ func (response DeleteFile500JSONResponse) VisitDeleteFileResponse(w http.Respons return json.NewEncoder(w).Encode(response) } +type DownloadDirZipRequestObject struct { + Params DownloadDirZipParams +} + +type DownloadDirZipResponseObject interface { + VisitDownloadDirZipResponse(w http.ResponseWriter) error +} + +type DownloadDirZip200ApplicationzipResponse struct { + Body io.Reader + ContentLength int64 +} + +func (response DownloadDirZip200ApplicationzipResponse) VisitDownloadDirZipResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/zip") + if response.ContentLength != 0 { + w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength)) + } + w.WriteHeader(200) + + if closer, ok := response.Body.(io.ReadCloser); ok { + defer closer.Close() + } + _, err := io.Copy(w, response.Body) + return err +} + +type DownloadDirZip400JSONResponse struct{ BadRequestErrorJSONResponse } + +func (response DownloadDirZip400JSONResponse) VisitDownloadDirZipResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(400) + + return json.NewEncoder(w).Encode(response) +} + +type DownloadDirZip404JSONResponse struct{ NotFoundErrorJSONResponse } + +func (response DownloadDirZip404JSONResponse) VisitDownloadDirZipResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(404) + + return json.NewEncoder(w).Encode(response) +} + +type DownloadDirZip500JSONResponse struct{ InternalErrorJSONResponse } + +func (response DownloadDirZip500JSONResponse) VisitDownloadDirZipResponse(w http.ResponseWriter) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(500) + + return json.NewEncoder(w).Encode(response) +} + type FileInfoRequestObject struct { Params FileInfoParams } @@ -7104,6 +7346,9 @@ type StrictServerInterface interface { // Delete a file // (PUT /fs/delete_file) DeleteFile(ctx context.Context, request DeleteFileRequestObject) (DeleteFileResponseObject, error) + // Download a directory as a ZIP archive + // (GET /fs/download_dir_zip) + DownloadDirZip(ctx context.Context, request DownloadDirZipRequestObject) (DownloadDirZipResponseObject, error) // Get information about a file or directory // (GET /fs/file_info) FileInfo(ctx context.Context, request FileInfoRequestObject) (FileInfoResponseObject, error) @@ -7359,6 +7604,32 @@ func (sh *strictHandler) DeleteFile(w http.ResponseWriter, r *http.Request) { } } +// DownloadDirZip operation middleware +func (sh *strictHandler) DownloadDirZip(w http.ResponseWriter, r *http.Request, params DownloadDirZipParams) { + var request DownloadDirZipRequestObject + + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.DownloadDirZip(ctx, request.(DownloadDirZipRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "DownloadDirZip") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(DownloadDirZipResponseObject); ok { + if err := validResponse.VisitDownloadDirZipResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // FileInfo operation middleware func (sh *strictHandler) FileInfo(w http.ResponseWriter, r *http.Request, params FileInfoParams) { var request FileInfoRequestObject @@ -8024,80 +8295,82 @@ func (sh *strictHandler) StopRecording(w http.ResponseWriter, r *http.Request) { // Base64 encoded, gzipped, json marshaled Swagger object var swaggerSpec = []string{ - "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", } // GetSwagger returns the content of the embedded swagger specification file diff --git a/server/openapi.yaml b/server/openapi.yaml index 199bf166..4f4f8e00 100644 --- a/server/openapi.yaml +++ b/server/openapi.yaml @@ -380,6 +380,34 @@ paths: "500": $ref: "#/components/responses/InternalError" + /fs/download_dir_zip: + get: + summary: Download a directory as a ZIP archive + description: Returns a ZIP file containing the contents of the specified directory. + operationId: downloadDirZip + parameters: + - name: path + in: query + required: true + schema: + type: string + pattern: "^/.*" + description: Absolute directory path to archive and download. + responses: + "200": + description: ZIP archive of the requested directory + content: + application/zip: + schema: + type: string + format: binary + "400": + $ref: "#/components/responses/BadRequestError" + "404": + $ref: "#/components/responses/NotFoundError" + "500": + $ref: "#/components/responses/InternalError" + /fs/write_file: put: summary: Write or create a file From 0e8340217b97c55829a027ea404152ecfeb3153f Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 21:51:48 +0000 Subject: [PATCH 13/24] disable scale to zero on startup for the headless image --- images/chromium-headless/image/wrapper.sh | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/images/chromium-headless/image/wrapper.sh b/images/chromium-headless/image/wrapper.sh index 72dee2df..422f0698 100755 --- a/images/chromium-headless/image/wrapper.sh +++ b/images/chromium-headless/image/wrapper.sh @@ -9,6 +9,27 @@ if [ -z "${WITH_DOCKER:-}" ]; then mount -t tmpfs tmpfs /dev/shm fi +# We disable scale-to-zero for the lifetime of this script and restore +# the original setting on exit. +SCALE_TO_ZERO_FILE="/uk/libukp/scale_to_zero_disable" +scale_to_zero_write() { + local char="$1" + # Skip when not running inside Unikraft Cloud (control file absent) + if [[ -e "$SCALE_TO_ZERO_FILE" ]]; then + # Write the character, but do not fail the whole script if this errors out + echo -n "$char" > "$SCALE_TO_ZERO_FILE" 2>/dev/null || \ + echo "[wrapper] Failed to write to scale-to-zero control file" >&2 + fi +} +disable_scale_to_zero() { scale_to_zero_write "+"; } +enable_scale_to_zero() { scale_to_zero_write "-"; } + +# Disable scale-to-zero for the duration of the script when not running under Docker +if [[ -z "${WITHDOCKER:-}" ]]; then + echo "[wrapper] Disabling scale-to-zero" + disable_scale_to_zero +fi + # if CHROMIUM_FLAGS is not set, default to the flags used in playwright_stealth if [ -z "${CHROMIUM_FLAGS:-}" ]; then CHROMIUM_FLAGS="--accept-lang=en-US,en \ @@ -125,6 +146,8 @@ export CHROME_PORT="${CHROME_PORT:-9222}" # Cleanup handler cleanup () { echo "[wrapper] Cleaning up..." + # Re-enable scale-to-zero if the script terminates early + enable_scale_to_zero supervisorctl -c /etc/supervisor/supervisord.conf stop chromium || true supervisorctl -c /etc/supervisor/supervisord.conf stop xvfb || true supervisorctl -c /etc/supervisor/supervisord.conf stop dbus || true @@ -187,5 +210,9 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then fi echo "[wrapper] startup complete!" +# Re-enable scale-to-zero once startup has completed (when not under Docker) +if [[ -z "${WITHDOCKER:-}" ]]; then + enable_scale_to_zero +fi # Keep the container running while streaming logs wait From 6c61a6042da14beebbacfbedbc19a5047b48b03f Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 13 Aug 2025 23:07:26 +0000 Subject: [PATCH 14/24] draft e2e test of saving user data --- .github/workflows/server-test.yaml | 26 +++ server/e2e/e2e_chromium_test.go | 352 +++++++++++++++++++++++++++++ server/go.mod | 7 + server/go.sum | 19 ++ 4 files changed, 404 insertions(+) create mode 100644 server/e2e/e2e_chromium_test.go diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml index 90300cd2..af35f174 100644 --- a/.github/workflows/server-test.yaml +++ b/.github/workflows/server-test.yaml @@ -5,6 +5,7 @@ on: branches: ["main"] pull_request: branches: ["main"] + workflow_dispatch: jobs: test: @@ -23,6 +24,31 @@ jobs: - name: Install Chromium run: sudo apt-get update && (sudo apt-get install -y chromium || sudo apt-get install -y chromium-browser) + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # - name: Log in to GitHub Container Registry (for buildkit base) + # uses: docker/login-action@v3 + # with: + # registry: ghcr.io + # username: ${{ github.actor }} + # password: ${{ secrets.GITHUB_TOKEN }} + + - name: Prepare env for image scripts + run: echo "UKC_TOKEN=dummy" >> $GITHUB_ENV && echo "UKC_METRO=dummy" >> $GITHUB_ENV + + - name: Build chromium-headful-test image + run: | + set -eux + export UKC_TOKEN=dummy UKC_METRO=dummy IMAGE=onkernel/chromium-headful-test:latest + bash images/chromium-headful/build-docker.sh + + - name: Build chromium-headless-test image + run: | + set -eux + export UKC_TOKEN=dummy UKC_METRO=dummy IMAGE=onkernel/chromium-headless-test:latest + bash images/chromium-headless/build-docker.sh + - name: Run server Makefile tests run: make test working-directory: server diff --git a/server/e2e/e2e_chromium_test.go b/server/e2e/e2e_chromium_test.go new file mode 100644 index 00000000..de94a123 --- /dev/null +++ b/server/e2e/e2e_chromium_test.go @@ -0,0 +1,352 @@ +package e2e + +import ( + "archive/zip" + "bytes" + "context" + "fmt" + "io" + "mime/multipart" + "net" + "net/http" + "os" + "os/exec" + "testing" + "time" + + "github.com/chromedp/cdproto/network" + "github.com/chromedp/chromedp" + instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" +) + +const ( + headfulImage = "onkernel/chromium-headful-test:latest" + headlessImage = "onkernel/chromium-headless-test:latest" + headfulName = "chromium-headful-test" + headlessName = "chromium-headless-test" + apiBaseURL = "http://127.0.0.1:444" + devtoolsHTTP = "http://127.0.0.1:9222" +) + +func TestChromiumHeadfulPersistence(t *testing.T) { + runChromiumPersistenceFlow(t, headfulImage, headfulName, true) +} + +func TestChromiumHeadlessPersistence(t *testing.T) { + runChromiumPersistenceFlow(t, headlessImage, headlessName, false) +} + +func runChromiumPersistenceFlow(t *testing.T, image, name string, runAsRoot bool) { + t.Helper() + + // Ensure docker available early + if _, err := exec.LookPath("docker"); err != nil { + t.Skipf("docker not available: %v", err) + } + + // Step 1: start container + mustStopContainer(name) + env := map[string]string{ + "WITH_KERNEL_IMAGES_API": "true", + "WITH_DOCKER": "true", + "CHROMIUM_FLAGS": "--no-sandbox --disable-dev-shm-usage --disable-gpu --start-maximized --disable-software-rasterizer --remote-allow-origins=* --no-zygote --user-data-dir=/home/kernel/user-data", + } + if runAsRoot { + env["RUN_AS_ROOT"] = "true" + } + if err := runContainer(image, name, env); err != nil { + t.Fatalf("failed to start container %s: %v", image, err) + } + defer mustStopContainer(name) + + // Wait for API and devtools + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + if err := waitHTTP(ctx, apiBaseURL+"/spec.yaml"); err != nil { + t.Fatalf("api not ready: %v", err) + } + wsURL, err := waitDevtoolsWS(ctx) + if err != nil { + t.Fatalf("devtools not ready: %v", err) + } + + // Step 2: set a cookie via CDP + cookieName := "ki_e2e_cookie" + cookieValue := fmt.Sprintf("v_%d", time.Now().UnixNano()) + if err := setCookieViaDevtools(ctx, wsURL, cookieName, cookieValue); err != nil { + t.Fatalf("failed to set cookie: %v", err) + } + + // Step 3: download user-data zip via API + zipBytes, err := downloadUserDataZip(ctx) + if err != nil { + t.Fatalf("failed to download user data zip: %v", err) + } + // quick sanity check: is a valid zip + if err := validateZip(zipBytes); err != nil { + t.Fatalf("invalid zip downloaded: %v", err) + } + + // Step 4: kill the container + mustStopContainer(name) + + // Step 5: start a-new and wait + if err := runContainer(image, name, env); err != nil { + t.Fatalf("failed to restart container %s: %v", image, err) + } + if err := waitHTTP(ctx, apiBaseURL+"/spec.yaml"); err != nil { + t.Fatalf("api not ready after restart: %v", err) + } + + // Step 6: upload the zip back to /home/kernel/user-data + if err := uploadUserDataZip(ctx, zipBytes); err != nil { + t.Fatalf("failed to upload user data zip: %v", err) + } + + // Step 7: restart chromium only via exec endpoint + if err := restartChromiumViaAPI(ctx); err != nil { + t.Fatalf("failed to restart chromium: %v", err) + } + + // Wait for chromium up (supervisorctl avail as requested + devtools) + if err := waitSupervisorAvail(ctx); err != nil { + t.Fatalf("supervisorctl avail failed: %v", err) + } + wsURL, err = waitDevtoolsWS(ctx) + if err != nil { + t.Fatalf("devtools not ready after restart: %v", err) + } + + // Final: verify cookie persists + got, err := getCookieViaDevtools(ctx, wsURL, cookieName) + if err != nil { + t.Fatalf("failed to read cookie: %v", err) + } + if got != cookieValue { + t.Fatalf("cookie mismatch after restore: got %q want %q", got, cookieValue) + } +} + +func runContainer(image, name string, env map[string]string) error { + args := []string{ + "run", "-d", + "--name", name, + "--privileged", + "--tmpfs", "/dev/shm:size=2g", + "-p", "9222:9222", + "-p", "444:10001", + } + for k, v := range env { + args = append(args, "-e", fmt.Sprintf("%s=%s", k, v)) + } + args = append(args, image) + cmd := exec.Command("docker", args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} + +func mustStopContainer(name string) { + _ = exec.Command("docker", "kill", name).Run() + _ = exec.Command("docker", "rm", "-f", name).Run() +} + +func waitHTTP(ctx context.Context, url string) error { + client := &http.Client{Timeout: 5 * time.Second} + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + resp, err := client.Do(req) + if err == nil && resp.StatusCode >= 200 && resp.StatusCode < 500 { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + return nil + } + if resp != nil && resp.Body != nil { + io.Copy(io.Discard, resp.Body) + resp.Body.Close() + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func waitTCP(ctx context.Context, hostport string) error { + d := net.Dialer{Timeout: 2 * time.Second} + ticker := time.NewTicker(300 * time.Millisecond) + defer ticker.Stop() + for { + conn, err := d.DialContext(ctx, "tcp", hostport) + if err == nil { + conn.Close() + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func waitDevtoolsWS(ctx context.Context) (string, error) { + if err := waitTCP(ctx, "127.0.0.1:9222"); err != nil { + return "", err + } + // The proxy ignores request path and forwards to the upstream ws path. + return "ws://127.0.0.1:9222/devtools/browser", nil +} + +func setCookieViaDevtools(ctx context.Context, wsURL, name, value string) error { + allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, wsURL) + defer cancel() + cctx, cancel2 := chromedp.NewContext(allocCtx) + defer cancel2() + // We can set cookie for example.com to avoid site-dependence + url := "https://example.com" + return chromedp.Run(cctx, + network.Enable(), + chromedp.ActionFunc(func(ctx context.Context) error { + params := []*network.CookieParam{ + {Name: name, Value: value, URL: url}, + } + return network.SetCookies(params).Do(ctx) + }), + ) +} + +func getCookieViaDevtools(ctx context.Context, wsURL, name string) (string, error) { + allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, wsURL) + defer cancel() + cctx, cancel2 := chromedp.NewContext(allocCtx) + defer cancel2() + var cookies []*network.Cookie + url := "https://example.com" + err := chromedp.Run(cctx, + network.Enable(), + chromedp.ActionFunc(func(ctx context.Context) error { + cs, err := network.GetCookies().WithURLs([]string{url}).Do(ctx) + if err != nil { + return err + } + cookies = cs + return nil + }), + ) + if err != nil { + return "", err + } + for _, c := range cookies { + if c.Name == name { + return c.Value, nil + } + } + return "", fmt.Errorf("cookie %q not found", name) +} + +func apiClient() (*instanceoapi.ClientWithResponses, error) { + return instanceoapi.NewClientWithResponses(apiBaseURL, instanceoapi.WithHTTPClient(http.DefaultClient)) +} + +func downloadUserDataZip(ctx context.Context) ([]byte, error) { + client, err := apiClient() + if err != nil { + return nil, err + } + params := &instanceoapi.DownloadDirZipParams{Path: "/home/kernel/user-data"} + rsp, err := client.DownloadDirZip(ctx, params) + if err != nil { + return nil, err + } + defer rsp.Body.Close() + return io.ReadAll(rsp.Body) +} + +func uploadUserDataZip(ctx context.Context, zipBytes []byte) error { + client, err := apiClient() + if err != nil { + return err + } + var body bytes.Buffer + w := multipart.NewWriter(&body) + fw, err := w.CreateFormFile("zip_file", "user-data.zip") + if err != nil { + return err + } + if _, err := io.Copy(fw, bytes.NewReader(zipBytes)); err != nil { + return err + } + if err := w.WriteField("dest_path", "/home/kernel/user-data"); err != nil { + return err + } + if err := w.Close(); err != nil { + return err + } + _, err = client.UploadZipWithBodyWithResponse(ctx, w.FormDataContentType(), &body) + return err +} + +func restartChromiumViaAPI(ctx context.Context) error { + client, err := apiClient() + if err != nil { + return err + } + // Restart chromium service + req := instanceoapi.ProcessExecJSONRequestBody{ + Command: "supervisorctl", + Args: &[]string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"}, + } + _, err = client.ProcessExecWithResponse(ctx, req) + if err != nil { + return err + } + return nil +} + +func waitSupervisorAvail(ctx context.Context) error { + client, err := apiClient() + if err != nil { + return err + } + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + for { + req := instanceoapi.ProcessExecJSONRequestBody{ + Command: "supervisorctl", + Args: &[]string{"-c", "/etc/supervisor/supervisord.conf", "avail"}, + } + rsp, err := client.ProcessExecWithResponse(ctx, req) + if err == nil && rsp != nil && rsp.JSON200 != nil && rsp.JSON200.ExitCode != nil && *rsp.JSON200.ExitCode == 0 { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-ticker.C: + } + } +} + +func validateZip(b []byte) error { + r, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + return err + } + // Ensure at least one file + if len(r.File) == 0 { + return fmt.Errorf("empty zip") + } + // Try opening first file header to sanity-check + f := r.File[0] + rc, err := f.Open() + if err != nil { + return err + } + _, _ = io.Copy(io.Discard, rc) + rc.Close() + return nil +} diff --git a/server/go.mod b/server/go.mod index c403a035..cbc47b50 100644 --- a/server/go.mod +++ b/server/go.mod @@ -3,6 +3,8 @@ module github.com/onkernel/kernel-images/server go 1.24.3 require ( + github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 + github.com/chromedp/chromedp v0.14.1 github.com/fsnotify/fsnotify v1.9.0 github.com/getkin/kin-openapi v0.132.0 github.com/ghodss/yaml v1.0.0 @@ -18,9 +20,14 @@ require ( require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect github.com/go-openapi/swag v0.23.0 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect diff --git a/server/go.sum b/server/go.sum index f69e6f11..ed496358 100644 --- a/server/go.sum +++ b/server/go.sum @@ -2,6 +2,12 @@ github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMz github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= +github.com/chromedp/chromedp v0.14.1 h1:0uAbnxewy/Q+Bg7oafVePE/6EXEho9hnaC38f+TTENg= +github.com/chromedp/chromedp v0.14.1/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -13,12 +19,20 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -32,6 +46,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= @@ -44,6 +60,8 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -61,6 +79,7 @@ golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 567e955836a182ff75e4e7e7441c8fe799ae5b8d Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Mon, 18 Aug 2025 13:30:38 +0000 Subject: [PATCH 15/24] user profile persistence passing --- images/chromium-headful/Dockerfile | 5 +- images/chromium-headful/start-chromium.sh | 6 + .../supervisor/services/chromium.conf | 2 - images/chromium-headful/wrapper.sh | 52 +- images/chromium-headless/image/Dockerfile | 5 +- .../chromium-headless/image/start-chromium.sh | 10 +- .../image/supervisor/services/chromium.conf | 2 - images/chromium-headless/image/wrapper.sh | 42 +- images/chromium-headless/run-docker.sh | 8 +- server/cmd/api/api/fs.go | 13 +- server/cmd/api/main.go | 17 + server/e2e/cookie_debug.sh | 77 ++ server/e2e/e2e_chromium_test.go | 1070 ++++++++++++++--- server/e2e/playwright/.gitignore | 6 + server/e2e/playwright/README.md | 74 ++ server/e2e/playwright/index.ts | 411 +++++++ .../localstorage-verify-initial.png | Bin 0 -> 13628 bytes server/e2e/playwright/package.json | 20 + server/e2e/playwright/pnpm-lock.yaml | 343 ++++++ server/e2e/playwright/tsconfig.json | 19 + server/go.mod | 19 +- server/go.sum | 45 +- server/lib/ziputil/ziputil.go | 22 +- 23 files changed, 2052 insertions(+), 216 deletions(-) create mode 100755 server/e2e/cookie_debug.sh create mode 100644 server/e2e/playwright/.gitignore create mode 100644 server/e2e/playwright/README.md create mode 100644 server/e2e/playwright/index.ts create mode 100644 server/e2e/playwright/localstorage-verify-initial.png create mode 100644 server/e2e/playwright/package.json create mode 100644 server/e2e/playwright/pnpm-lock.yaml create mode 100644 server/e2e/playwright/tsconfig.json diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 340c3902..9e9b25dc 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -136,9 +136,9 @@ RUN set -eux; \ apt-get clean -y; \ rm -rf /var/lib/apt/lists/* /var/cache/apt/ -# install chromium +# install chromium and sqlite3 for debugging the cookies file RUN add-apt-repository -y ppa:xtradeb/apps -RUN apt update -y && apt install -y chromium +RUN apt update -y && apt install -y chromium sqlite3 # setup desktop env & app ENV DISPLAY_NUM=1 @@ -165,7 +165,6 @@ COPY bin/kernel-images-api /usr/local/bin/kernel-images-api ENV WITH_KERNEL_IMAGES_API=false RUN useradd -m -s /bin/bash kernel -RUN cp -r ./user-data /home/kernel/user-data ENTRYPOINT [ "/wrapper.sh" ] diff --git a/images/chromium-headful/start-chromium.sh b/images/chromium-headful/start-chromium.sh index 8bfd528f..7e7e09b6 100644 --- a/images/chromium-headful/start-chromium.sh +++ b/images/chromium-headful/start-chromium.sh @@ -26,6 +26,9 @@ RUN_AS_ROOT="${RUN_AS_ROOT:-false}" if [[ "$RUN_AS_ROOT" == "true" ]]; then exec chromium \ --remote-debugging-port="$INTERNAL_PORT" \ + --user-data-dir=/home/kernel/user-data \ + --password-store=basic \ + --no-first-run \ ${CHROMIUM_FLAGS:-} else exec runuser -u kernel -- env \ @@ -36,6 +39,9 @@ else HOME=/home/kernel \ chromium \ --remote-debugging-port="$INTERNAL_PORT" \ + --user-data-dir=/home/kernel/user-data \ + --password-store=basic \ + --no-first-run \ ${CHROMIUM_FLAGS:-} fi diff --git a/images/chromium-headful/supervisor/services/chromium.conf b/images/chromium-headful/supervisor/services/chromium.conf index ed48e450..95e696c6 100644 --- a/images/chromium-headful/supervisor/services/chromium.conf +++ b/images/chromium-headful/supervisor/services/chromium.conf @@ -5,5 +5,3 @@ autorestart=true startsecs=2 stdout_logfile=/var/log/supervisord/chromium redirect_stderr=true - - diff --git a/images/chromium-headful/wrapper.sh b/images/chromium-headful/wrapper.sh index f7d691db..bd2a9dcf 100755 --- a/images/chromium-headful/wrapper.sh +++ b/images/chromium-headful/wrapper.sh @@ -41,25 +41,43 @@ fi # [ERROR:crypto/nss_util.cc:48] Failed to create /home/kernel/.pki/nssdb ... # dconf-CRITICAL **: unable to create directory '/home/kernel/.cache/dconf' # Pre-create them and hand ownership to the user so the messages disappear. +# When RUN_AS_ROOT is true, we skip ownership changes since we're running as root. -dirs=( - /home/kernel/user-data - /home/kernel/.config/chromium - /home/kernel/.pki/nssdb - /home/kernel/.cache/dconf - /tmp - /var/log - /var/log/supervisord -) - -for dir in "${dirs[@]}"; do - if [ ! -d "$dir" ]; then - mkdir -p "$dir" - fi -done +if [[ "${RUN_AS_ROOT:-}" != "true" ]]; then + dirs=( + /home/kernel/user-data + /home/kernel/.config/chromium + /home/kernel/.pki/nssdb + /home/kernel/.cache/dconf + /tmp + /var/log + /var/log/supervisord + ) + + for dir in "${dirs[@]}"; do + if [ ! -d "$dir" ]; then + mkdir -p "$dir" + fi + done -# Ensure correct ownership (ignore errors if already correct) -chown -R kernel:kernel /home/kernel /home/kernel/user-data /home/kernel/.config /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true + # Ensure correct ownership (ignore errors if already correct) + chown -R kernel:kernel /home/kernel /home/kernel/user-data /home/kernel/.config /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true +else + # When running as root, just create the necessary directories without ownership changes + dirs=( + /tmp + /var/log + /var/log/supervisord + /home/kernel + /home/kernel/user-data + ) + + for dir in "${dirs[@]}"; do + if [ ! -d "$dir" ]; then + mkdir -p "$dir" + fi + done +fi # ----------------------------------------------------------------------------- # Dynamic log aggregation for /var/log/supervisord ----------------------------- diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index 6c42cb8e..064d13c2 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -32,8 +32,9 @@ RUN set -xe; \ software-properties-common \ supervisor; +# install chromium and sqlite3 for debugging the cookies file RUN add-apt-repository -y ppa:xtradeb/apps -RUN apt update -y && apt install -y chromium +RUN apt update -y && apt install -y chromium sqlite3 # Install FFmpeg (latest static build) for the recording server RUN set -eux; \ @@ -66,3 +67,5 @@ COPY ./supervisor/services/ /etc/supervisor/conf.d/services/ # Copy the kernel-images API binary built during the build process COPY bin/kernel-images-api /usr/local/bin/kernel-images-api ENV WITH_KERNEL_IMAGES_API=false + +ENTRYPOINT [ "/usr/bin/wrapper.sh" ] diff --git a/images/chromium-headless/image/start-chromium.sh b/images/chromium-headless/image/start-chromium.sh index 2037c323..75af24f4 100644 --- a/images/chromium-headless/image/start-chromium.sh +++ b/images/chromium-headless/image/start-chromium.sh @@ -22,9 +22,12 @@ RUN_AS_ROOT="${RUN_AS_ROOT:-false}" if [[ "$RUN_AS_ROOT" == "true" ]]; then exec chromium \ - --headless \ + --headless=new \ --remote-debugging-port="$INTERNAL_PORT" \ --remote-allow-origins=* \ + --user-data-dir=/home/kernel/user-data \ + --password-store=basic \ + --no-first-run \ ${CHROMIUM_FLAGS:-} else exec runuser -u kernel -- env \ @@ -34,9 +37,12 @@ else XDG_CACHE_HOME=/home/kernel/.cache \ HOME=/home/kernel \ chromium \ - --headless \ + --headless=new \ --remote-debugging-port="$INTERNAL_PORT" \ --remote-allow-origins=* \ + --user-data-dir=/home/kernel/user-data \ + --password-store=basic \ + --no-first-run \ ${CHROMIUM_FLAGS:-} fi diff --git a/images/chromium-headless/image/supervisor/services/chromium.conf b/images/chromium-headless/image/supervisor/services/chromium.conf index 0862294f..2fb10432 100644 --- a/images/chromium-headless/image/supervisor/services/chromium.conf +++ b/images/chromium-headless/image/supervisor/services/chromium.conf @@ -5,5 +5,3 @@ autorestart=true startsecs=2 stdout_logfile=/var/log/supervisord/chromium redirect_stderr=true - - diff --git a/images/chromium-headless/image/wrapper.sh b/images/chromium-headless/image/wrapper.sh index 422f0698..a7159a61 100755 --- a/images/chromium-headless/image/wrapper.sh +++ b/images/chromium-headless/image/wrapper.sh @@ -86,20 +86,36 @@ export CHROMIUM_FLAGS # ----------------------------------------------------------------------------- # House-keeping for the unprivileged "kernel" user ---------------------------- +# When RUN_AS_ROOT is true, we skip ownership changes since we're running as root. # ----------------------------------------------------------------------------- -dirs=( - /home/kernel/.pki/nssdb - /home/kernel/.cache/dconf - /var/log - /var/log/supervisord -) -for dir in "${dirs[@]}"; do - if [ ! -d "$dir" ]; then - mkdir -p "$dir" - fi -done -# Ensure correct ownership (ignore errors if already correct) -chown -R kernel:kernel /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true +if [[ "${RUN_AS_ROOT:-}" != "true" ]]; then + dirs=( + /home/kernel/.pki/nssdb + /home/kernel/.cache/dconf + /var/log + /var/log/supervisord + ) + for dir in "${dirs[@]}"; do + if [ ! -d "$dir" ]; then + mkdir -p "$dir" + fi + done + # Ensure correct ownership (ignore errors if already correct) + chown -R kernel:kernel /home/kernel/.pki /home/kernel/.cache 2>/dev/null || true +else + # When running as root, just create the necessary directories without ownership changes + dirs=( + /var/log + /var/log/supervisord + /home/kernel + /home/kernel/user-data + ) + for dir in "${dirs[@]}"; do + if [ ! -d "$dir" ]; then + mkdir -p "$dir" + fi + done +fi # ----------------------------------------------------------------------------- # Dynamic log aggregation for /var/log/supervisord ----------------------------- diff --git a/images/chromium-headless/run-docker.sh b/images/chromium-headless/run-docker.sh index 1057b434..57fa74fe 100755 --- a/images/chromium-headless/run-docker.sh +++ b/images/chromium-headless/run-docker.sh @@ -24,5 +24,11 @@ if [[ "${WITH_KERNEL_IMAGES_API:-}" == "true" ]]; then RUN_ARGS+=( -v "$HOST_RECORDINGS_DIR:/recordings" ) fi +# If a positional argument is given, use it as the entrypoint +ENTRYPOINT_ARG=() +if [[ $# -ge 1 && -n "$1" ]]; then + ENTRYPOINT_ARG+=(--entrypoint "$1") +fi + docker rm -f "$NAME" 2>/dev/null || true -docker run -it --rm "${RUN_ARGS[@]}" "$IMAGE" /usr/bin/wrapper.sh +docker run -it --rm "${ENTRYPOINT_ARG[@]}" "${RUN_ARGS[@]}" "$IMAGE" diff --git a/server/cmd/api/api/fs.go b/server/cmd/api/api/fs.go index 3d0ec67e..980236b0 100644 --- a/server/cmd/api/api/fs.go +++ b/server/cmd/api/api/fs.go @@ -832,7 +832,18 @@ func (s *ApiService) DownloadDirZip(ctx context.Context, request oapi.DownloadDi // Build zip in-memory to provide a single streaming response zipBytes, err := ziputil.ZipDir(path) if err != nil { - log.Error("failed to create zip archive", "err", err, "path", path) + // Add extra diagnostics for common failure causes + // Check if directory is readable and walkable + // We avoid heavy recursion here; just attempt to open directory and read one entry + var readErr error + f, oerr := os.Open(path) + if oerr != nil { + readErr = oerr + } else { + _, readErr = f.Readdir(1) + _ = f.Close() + } + log.Error("failed to create zip archive", "err", err, "path", path, "read_probe_err", readErr) return oapi.DownloadDirZip500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to create zip"}}, nil } diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index cae2a278..57806754 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -2,9 +2,11 @@ package main import ( "context" + "encoding/json" "fmt" "log/slog" "net/http" + "net/url" "os" "os/exec" "os/signal" @@ -123,6 +125,21 @@ func main() { }) }, ) + // Expose a minimal /json/version endpoint so clients that attempt to + // resolve a browser websocket URL via HTTP can succeed. We map the + // upstream path onto this proxy's host:port so clients connect back to us. + rDevtools.Get("/json/version", func(w http.ResponseWriter, r *http.Request) { + current := upstreamMgr.Current() + if current == "" { + http.Error(w, "upstream not ready", http.StatusServiceUnavailable) + return + } + proxyWSURL := (&url.URL{Scheme: "ws", Host: r.Host}).String() + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{ + "webSocketDebuggerUrl": proxyWSURL, + }) + }) rDevtools.Get("/*", func(w http.ResponseWriter, r *http.Request) { devtoolsproxy.WebSocketProxyHandler(upstreamMgr, slogger).ServeHTTP(w, r) }) diff --git a/server/e2e/cookie_debug.sh b/server/e2e/cookie_debug.sh new file mode 100755 index 00000000..82e8ebf4 --- /dev/null +++ b/server/e2e/cookie_debug.sh @@ -0,0 +1,77 @@ +#!/bin/bash + +echo "=== Container User Info ===" +echo "Current user: $(whoami)" +echo "User ID: $(id)" +echo "Home directory: $HOME" +echo "" + +echo "=== Chromium Process Info ===" +echo "Chromium processes:" +ps aux | grep -i chromium | grep -v grep || echo "No chromium processes found" +echo "" + +echo "=== User Data Directory Info ===" +USER_DATA_DIR="/home/kernel/user-data" +if [ -d "$USER_DATA_DIR" ]; then + echo "User data directory exists: $USER_DATA_DIR" + echo "Owner: $(ls -ld "$USER_DATA_DIR")" + echo "Contents:" + ls -la "$USER_DATA_DIR" || echo "Failed to list contents" + + COOKIES_DIR="$USER_DATA_DIR/Default" + if [ -d "$COOKIES_DIR" ]; then + echo "" + echo "Default directory exists: $COOKIES_DIR" + echo "Owner: $(ls -ld "$COOKIES_DIR")" + echo "Contents:" + ls -la "$COOKIES_DIR" || echo "Failed to list contents" + + COOKIES_FILE="$COOKIES_DIR/Cookies" + if [ -f "$COOKIES_FILE" ]; then + echo "" + echo "Cookies file exists: $COOKIES_FILE" + echo "Owner: $(ls -ld "$COOKIES_FILE")" + echo "Modification time: $(stat -c %y "$COOKIES_FILE")" + echo "File size: $(stat -c %s "$COOKIES_FILE") bytes" + if command -v file >/dev/null 2>&1; then + echo "File type: $(file "$COOKIES_FILE")" + else + echo "File type: unknown (file command not found)" + fi + + # Try to inspect the cookies database for specific cookies + echo "" + echo "=== Cookie Database Inspection ===" + if command -v sqlite3 >/dev/null 2>&1; then + echo "Checking for e2e_cookie:" + sqlite3 "$COOKIES_FILE" "SELECT name, value, host_key, path, expires_utc FROM cookies WHERE name='e2e_cookie';" 2>/dev/null || echo "Failed to query e2e_cookie" + echo "Checking for .x.com cookie:" + sqlite3 "$COOKIES_FILE" "SELECT name, value, host_key, path, expires_utc FROM cookies WHERE host_key='.x.com';" 2>/dev/null || echo "Failed to query .x.com cookie" + echo "All cookies in database:" + sqlite3 "$COOKIES_FILE" "SELECT name, value, host_key, path FROM cookies LIMIT 10;" 2>/dev/null || echo "Failed to query cookies" + else + echo "sqlite3 not available, cannot inspect cookie database" + fi + else + echo "" + echo "Cookies file does not exist: $COOKIES_FILE" + fi + else + echo "" + echo "Default directory does not exist: $COOKIES_DIR" + fi +else + echo "User data directory does not exist: $USER_DATA_DIR" +fi + +echo "" +echo "=== Supervisor Status ===" +supervisorctl -c /etc/supervisor/supervisord.conf status chromium || true + +echo "" +echo "=== Environment Variables ===" +echo "RUN_AS_ROOT: ${RUN_AS_ROOT:-not set}" +echo "USER: ${USER:-not set}" +echo "HOME: ${HOME:-not set}" +echo "PWD: $PWD" diff --git a/server/e2e/e2e_chromium_test.go b/server/e2e/e2e_chromium_test.go index de94a123..807ec521 100644 --- a/server/e2e/e2e_chromium_test.go +++ b/server/e2e/e2e_chromium_test.go @@ -2,8 +2,14 @@ package e2e import ( "archive/zip" + "bufio" "bytes" "context" + "crypto/rand" + "database/sql" + "encoding/base64" + "encoding/hex" + "encoding/json" "fmt" "io" "mime/multipart" @@ -11,147 +17,348 @@ import ( "net/http" "os" "os/exec" + "path/filepath" + "strings" "testing" "time" - "github.com/chromedp/cdproto/network" - "github.com/chromedp/chromedp" + "log/slog" + "text/template" + + _ "github.com/glebarez/sqlite" + logctx "github.com/onkernel/kernel-images/server/lib/logger" instanceoapi "github.com/onkernel/kernel-images/server/lib/oapi" ) const ( headfulImage = "onkernel/chromium-headful-test:latest" headlessImage = "onkernel/chromium-headless-test:latest" - headfulName = "chromium-headful-test" - headlessName = "chromium-headless-test" - apiBaseURL = "http://127.0.0.1:444" - devtoolsHTTP = "http://127.0.0.1:9222" + containerName = "server-e2e-test" + // With host networking, the API listens on 10001 directly on the host + apiBaseURL = "http://127.0.0.1:10001" ) -func TestChromiumHeadfulPersistence(t *testing.T) { - runChromiumPersistenceFlow(t, headfulImage, headfulName, true) +// getPlaywrightPath returns the path to the playwright script +func getPlaywrightPath() string { + return "./playwright" +} + +// ensurePlaywrightDeps ensures playwright dependencies are installed +func ensurePlaywrightDeps(t *testing.T) { + t.Helper() + nodeModulesPath := getPlaywrightPath() + "/node_modules" + if _, err := os.Stat(nodeModulesPath); os.IsNotExist(err) { + t.Log("Installing playwright dependencies...") + cmd := exec.Command("pnpm", "install") + cmd.Dir = getPlaywrightPath() + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("Failed to install playwright dependencies: %v\nOutput: %s", err, string(output)) + } + t.Log("Playwright dependencies installed successfully") + } +} + +func TestChromiumHeadfulUserDataSaving(t *testing.T) { + ensurePlaywrightDeps(t) + runChromiumUserDataSavingFlow(t, headfulImage, containerName, true) } func TestChromiumHeadlessPersistence(t *testing.T) { - runChromiumPersistenceFlow(t, headlessImage, headlessName, false) + ensurePlaywrightDeps(t) + runChromiumUserDataSavingFlow(t, headlessImage, containerName, true) } -func runChromiumPersistenceFlow(t *testing.T, image, name string, runAsRoot bool) { +func runChromiumUserDataSavingFlow(t *testing.T, image, containerName string, runAsRoot bool) { t.Helper() - - // Ensure docker available early + logger := slog.New(slog.NewTextHandler(t.Output(), &slog.HandlerOptions{ + Level: slog.LevelInfo, + AddSource: false, + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == slog.TimeKey { + ts := a.Value.Time() + return slog.String(slog.TimeKey, ts.UTC().Format(time.RFC3339)) + } + return a + }, + })) + baseCtx := logctx.AddToContext(context.Background(), logger) + logger.Info("[e2e]", "action", "starting chromium cookie saving flow", "image", image, "name", containerName, "runAsRoot", runAsRoot) if _, err := exec.LookPath("docker"); err != nil { - t.Skipf("docker not available: %v", err) + t.Fatalf("[precheck] docker not available: %v", err) } - // Step 1: start container - mustStopContainer(name) + // Setup Phase + layout := createTestTempLayout(t) + logger.Info("[setup]", "base", layout.BaseDir, "zips", layout.ZipsDir, "restored", layout.RestoreDir) + logger.Info("[setup]", "action", "ensuring container is not running", "container", containerName) + if err := stopContainer(baseCtx, containerName); err != nil { + t.Fatalf("[setup] failed to stop container %s: %v", containerName, err) + } env := map[string]string{ "WITH_KERNEL_IMAGES_API": "true", "WITH_DOCKER": "true", - "CHROMIUM_FLAGS": "--no-sandbox --disable-dev-shm-usage --disable-gpu --start-maximized --disable-software-rasterizer --remote-allow-origins=* --no-zygote --user-data-dir=/home/kernel/user-data", + "RUN_AS_ROOT": fmt.Sprintf("%t", runAsRoot), + "USER": func() string { + if runAsRoot { + return "root" + } + return "kernel" + }(), + "WIDTH": "1024", + "HEIGHT": "768", + "ENABLE_WEBRTC": os.Getenv("ENABLE_WEBRTC"), + "NEKO_ICESERVERS": os.Getenv("NEKO_ICESERVERS"), } - if runAsRoot { - env["RUN_AS_ROOT"] = "true" + if strings.Contains(image, "headful") { + // headless image sets its own flags, so only do this for headful + env["CHROMIUM_FLAGS"] = "--no-sandbox --disable-dev-shm-usage --disable-gpu --start-maximized --disable-software-rasterizer --remote-allow-origins=* --no-zygote --password-store=basic --no-first-run" } - if err := runContainer(image, name, env); err != nil { - t.Fatalf("failed to start container %s: %v", image, err) + logger.Info("[setup]", "action", "starting container", "image", image, "name", containerName) + _, exitCh, err := runContainer(baseCtx, image, containerName, env) + if err != nil { + t.Fatalf("[setup] failed to start container %s: %v", image, err) } - defer mustStopContainer(name) + defer stopContainer(baseCtx, containerName) - // Wait for API and devtools - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + ctx, cancel := context.WithTimeout(baseCtx, 3*time.Minute) defer cancel() - if err := waitHTTP(ctx, apiBaseURL+"/spec.yaml"); err != nil { - t.Fatalf("api not ready: %v", err) + logger.Info("[setup]", "action", "waiting for API", "url", apiBaseURL+"/spec.yaml") + if err := waitHTTPOrExit(ctx, apiBaseURL+"/spec.yaml", exitCh); err != nil { + _ = dumpContainerDiagnostics(ctx, containerName) + t.Fatalf("[setup] api not ready: %v", err) } + logger.Info("[setup]", "action", "waiting for DevTools WebSocket") wsURL, err := waitDevtoolsWS(ctx) if err != nil { - t.Fatalf("devtools not ready: %v", err) + t.Fatalf("[setup] devtools not ready: %v", err) + } + + // Diagnostic Phase - Check file ownership and permissions before any navigations + logger.Info("[diagnostic]", "action", "checking file ownership and permissions") + if err := runCookieDebugScript(ctx); err != nil { + logger.Warn("[diagnostic]", "action", "cookie debug script failed", "error", err) + } else { + logger.Info("[diagnostic]", "action", "cookie debug script completed successfully") } - // Step 2: set a cookie via CDP - cookieName := "ki_e2e_cookie" - cookieValue := fmt.Sprintf("v_%d", time.Now().UnixNano()) - if err := setCookieViaDevtools(ctx, wsURL, cookieName, cookieValue); err != nil { - t.Fatalf("failed to set cookie: %v", err) + // Cookie Setting Phase + cookieName := "e2e_cookie" + randBytes := make([]byte, 16) + rand.Read(randBytes) + cookieValue := hex.EncodeToString(randBytes) + serverURL, stopServer := startCookieTestServer(t, cookieName, cookieValue) + defer stopServer() + + logger.Info("[cookies]", "action", "navigate set-cookie", "cookieName", cookieName, "cookieValue", cookieValue) + if err := navigateAndEnsureCookie(ctx, wsURL, serverURL+"/set-cookie", cookieName, cookieValue, "initial"); err != nil { + t.Fatalf("[cookies] failed to set/verify cookie: %v", err) + } + logger.Info("[cookies]", "action", "navigate get-cookies") + if err := navigateAndEnsureCookie(ctx, wsURL, serverURL+"/get-cookies", cookieName, cookieValue, "initial-get-page"); err != nil { + t.Fatalf("[cookies] failed to verify cookie on get-cookies: %v", err) } - // Step 3: download user-data zip via API + // Local Storage Setting Phase + localStorageKey := "e2e_localstorage_key" + randBytes = make([]byte, 16) + rand.Read(randBytes) + localStorageValue := hex.EncodeToString(randBytes) + logger.Info("[localstorage]", "action", "set and verify localStorage") + if err := setAndVerifyLocalStorage(ctx, wsURL, serverURL+"/set-cookie", localStorageKey, localStorageValue, "initial"); err != nil { + t.Fatalf("[localstorage] failed to set/verify localStorage: %v", err) + } + + // x.com Cookie Generation Phase + logger.Info("[x-cookies]", "action", "navigate to x.com and verify guest_id cookie") + if err := navigateToXAndVerifyCookie(ctx, wsURL, "initial"); err != nil { + logger.Warn("[x-cookies]", "message", fmt.Sprintf("failed to navigate to x.com and verify cookie: %v", err)) + } + + // Restart & Persistence Testing Phase + logger.Info("[restart]", "action", "stop chromium via supervisorctl") + if err := stopChromiumViaSupervisord(ctx); err != nil { + t.Fatalf("[restart] failed to stop chromium via supervisorctl: %v", err) + } + + // Check file state after stopping + logger.Info("[restart]", "action", "checking file state after stop") + if err := runCookieDebugScript(ctx); err != nil { + logger.Warn("[restart]", "action", "post-stop debug script failed", "error", err) + } else { + logger.Info("[restart]", "action", "post-stop debug script completed") + } + + logger.Info("[snapshot]", "action", "download user-data zip") zipBytes, err := downloadUserDataZip(ctx) if err != nil { - t.Fatalf("failed to download user data zip: %v", err) + t.Fatalf("[snapshot] download zip: %v", err) } - // quick sanity check: is a valid zip if err := validateZip(zipBytes); err != nil { - t.Fatalf("invalid zip downloaded: %v", err) + t.Fatalf("[snapshot] invalid zip: %v", err) } - - // Step 4: kill the container - mustStopContainer(name) - - // Step 5: start a-new and wait - if err := runContainer(image, name, env); err != nil { - t.Fatalf("failed to restart container %s: %v", image, err) + zipPath := filepath.Join(layout.ZipsDir, "user-data-original.zip") + if err := os.WriteFile(zipPath, zipBytes, 0600); err != nil { + t.Fatalf("[snapshot] write zip: %v", err) } - if err := waitHTTP(ctx, apiBaseURL+"/spec.yaml"); err != nil { - t.Fatalf("api not ready after restart: %v", err) + if err := unzipBytesToDir(zipBytes, layout.RestoreDir); err != nil { + t.Fatalf("[snapshot] unzip: %v", err) } - // Step 6: upload the zip back to /home/kernel/user-data - if err := uploadUserDataZip(ctx, zipBytes); err != nil { - t.Fatalf("failed to upload user data zip: %v", err) + if err := verifyCookieInLocalSnapshot(ctx, layout.RestoreDir, cookieName, cookieValue); err != nil { + logger.Warn("[snapshot]", "message", fmt.Sprintf("verify cookie in sqlite: %v", err)) + } + if err := deleteLocalSingletonLockFiles(layout.RestoreDir); err != nil { + t.Fatalf("[snapshot] delete local singleton locks: %v", err) + } + cleanZipBytes, err := zipDirToBytes(layout.RestoreDir) + if err != nil { + t.Fatalf("[snapshot] zip cleaned snapshot: %v", err) + } + cleanZipPath := filepath.Join(layout.ZipsDir, "user-data-cleaned.zip") + if err := os.WriteFile(cleanZipPath, cleanZipBytes, 0600); err != nil { + t.Fatalf("[snapshot] write cleaned zip: %v", err) + } + logger.Info("[snapshot]", "action", "delete remote user-data") + if err := deleteDirectoryViaAPI(ctx, "/home/kernel/user-data"); err != nil { + t.Fatalf("[snapshot] delete remote user-data: %v", err) + } + logger.Info("[snapshot]", "action", "upload cleaned zip", "bytes", len(cleanZipBytes)) + if err := uploadUserDataZip(ctx, cleanZipBytes); err != nil { + t.Fatalf("[snapshot] upload cleaned zip: %v", err) } - // Step 7: restart chromium only via exec endpoint - if err := restartChromiumViaAPI(ctx); err != nil { - t.Fatalf("failed to restart chromium: %v", err) + // Verify that the cookie exists in the container's cookies database after upload + logger.Info("[snapshot]", "action", "verifying cookie in container database", "cookieName", cookieName) + if err := verifyCookieInContainerDB(ctx, cookieName); err != nil { + logger.Warn("[snapshot]", "message", fmt.Sprintf("cookie not found in container database: %v", err)) } - // Wait for chromium up (supervisorctl avail as requested + devtools) - if err := waitSupervisorAvail(ctx); err != nil { - t.Fatalf("supervisorctl avail failed: %v", err) + if err := startChromiumViaAPI(ctx); err != nil { + t.Fatalf("[restart] start chromium: %v", err) } + logger.Info("[restart]", "action", "wait for DevTools") wsURL, err = waitDevtoolsWS(ctx) if err != nil { - t.Fatalf("devtools not ready after restart: %v", err) + t.Fatalf("[restart] devtools not ready: %v", err) } + logger.Info("[restart]", "action", "sleep to init", "seconds", 5) + time.Sleep(5 * time.Second) - // Final: verify cookie persists - got, err := getCookieViaDevtools(ctx, wsURL, cookieName) - if err != nil { - t.Fatalf("failed to read cookie: %v", err) + if err := navigateAndEnsureCookie(ctx, wsURL, serverURL+"/get-cookies", cookieName, cookieValue, "after-restart"); err != nil { + t.Fatalf("[final] cookie not persisted after restart: %v", err) } - if got != cookieValue { - t.Fatalf("cookie mismatch after restore: got %q want %q", got, cookieValue) + logger.Info("[final]", "result", "cookie verified after restart") + + // Verify Local Storage persistence + logger.Info("[final]", "action", "verifying localStorage persistence") + if err := verifyLocalStorage(ctx, wsURL, serverURL+"/set-cookie", localStorageKey, localStorageValue, "after-restart"); err != nil { + t.Fatalf("[final] localStorage not persisted after restart: %v", err) } + logger.Info("[final]", "result", "localStorage verified after restart") + + logger.Info("[final]", "result", "all persistence mechanisms verified after restart") } -func runContainer(image, name string, env map[string]string) error { +func runContainer(ctx context.Context, image, name string, env map[string]string) (*exec.Cmd, <-chan error, error) { + logger := logctx.FromContext(ctx) args := []string{ - "run", "-d", + "run", "--name", name, "--privileged", + "--network=host", "--tmpfs", "/dev/shm:size=2g", - "-p", "9222:9222", - "-p", "444:10001", } for k, v := range env { args = append(args, "-e", fmt.Sprintf("%s=%s", k, v)) } args = append(args, image) + + logger.Info("[docker]", "action", "run", "args", strings.Join(args, " ")) cmd := exec.Command("docker", args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - return cmd.Run() + if err := cmd.Start(); err != nil { + return nil, nil, err + } + + exitCh := make(chan error, 1) + go func() { + exitCh <- cmd.Wait() + }() + + return cmd, exitCh, nil } -func mustStopContainer(name string) { - _ = exec.Command("docker", "kill", name).Run() - _ = exec.Command("docker", "rm", "-f", name).Run() +func stopContainer(ctx context.Context, name string) error { + _ = exec.CommandContext(ctx, "docker", "kill", name).Run() + _ = exec.CommandContext(ctx, "docker", "rm", "-f", name).Run() + + // Wait loop to ensure the container is actually gone + const maxWait = 10 * time.Second + const pollInterval = 200 * time.Millisecond + deadline := time.Now().Add(maxWait) + var lastCheckErr error + for { + cmd := exec.CommandContext(ctx, "docker", "ps", "-a", "--filter", fmt.Sprintf("name=%s", name), "--format", "{{.Names}}") + out, err := cmd.Output() + if err != nil { + // If docker itself fails, break out (maybe docker is gone) + lastCheckErr = err + break + } + names := strings.Fields(string(out)) + found := false + for _, n := range names { + if n == name { + found = true + break + } + } + if !found { + break // container is gone + } + if time.Now().After(deadline) { + lastCheckErr = fmt.Errorf("timeout waiting for container %s to be removed", name) + break // give up after maxWait + } + time.Sleep(pollInterval) + } + + if lastCheckErr != nil { + return lastCheckErr + } + return nil +} + +// dumpContainerDiagnostics prints container logs and inspect to structured logger for debugging startup failures +func dumpContainerDiagnostics(ctx context.Context, name string) error { + logger := logctx.FromContext(ctx) + logger.Info("[docker]", "action", "collecting logs", "name", name) + logsCmd := exec.CommandContext(ctx, "docker", "logs", name) + logsOut, _ := logsCmd.CombinedOutput() + if len(logsOut) > 0 { + scanner := bufio.NewScanner(bytes.NewReader(logsOut)) + for scanner.Scan() { + logger.Info("[docker]", "action", "diag logs", "line", scanner.Text()) + } + } + logger.Info("[docker]", "action", "inspect", "name", name) + inspectCmd := exec.CommandContext(ctx, "docker", "inspect", name) + inspectOut, _ := inspectCmd.CombinedOutput() + if len(inspectOut) > 0 { + // Trim to a reasonable size + const max = 64 * 1024 + if len(inspectOut) > max { + inspectOut = inspectOut[:max] + } + scanner := bufio.NewScanner(bytes.NewReader(inspectOut)) + for scanner.Scan() { + logger.Info("[docker]", "action", "diag inspect", "line", scanner.Text()) + } + } + return nil } -func waitHTTP(ctx context.Context, url string) error { +func waitHTTPOrExit(ctx context.Context, url string, exitCh <-chan error) error { client := &http.Client{Timeout: 5 * time.Second} ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() @@ -170,6 +377,11 @@ func waitHTTP(ctx context.Context, url string) error { select { case <-ctx.Done(): return ctx.Err() + case err := <-exitCh: + if err != nil { + return fmt.Errorf("container exited while waiting for %s: %w", url, err) + } + return fmt.Errorf("container exited while waiting for %s", url) case <-ticker.C: } } @@ -197,73 +409,315 @@ func waitDevtoolsWS(ctx context.Context) (string, error) { if err := waitTCP(ctx, "127.0.0.1:9222"); err != nil { return "", err } - // The proxy ignores request path and forwards to the upstream ws path. - return "ws://127.0.0.1:9222/devtools/browser", nil + return "ws://127.0.0.1:9222/", nil } -func setCookieViaDevtools(ctx context.Context, wsURL, name, value string) error { - allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, wsURL) - defer cancel() - cctx, cancel2 := chromedp.NewContext(allocCtx) - defer cancel2() - // We can set cookie for example.com to avoid site-dependence - url := "https://example.com" - return chromedp.Run(cctx, - network.Enable(), - chromedp.ActionFunc(func(ctx context.Context) error { - params := []*network.CookieParam{ - {Name: name, Value: value, URL: url}, - } - return network.SetCookies(params).Do(ctx) - }), +// startCookieTestServer starts an HTTP server listening on 0.0.0.0 +// It serves two pages: +// - /set-cookie: sets a deterministic (passed in to server initialization) cookie if not present and displays cookie state +// - /get-cookies: just displays existing cookies without setting anything +func startCookieTestServer(t *testing.T, cookieName, cookieValue string) (url string, stop func()) { + mux := http.NewServeMux() + nameJS, err := json.Marshal(cookieName) + if err != nil { + t.Fatalf("failed to marshal cookieName: %v", err) + } + valueJS, err := json.Marshal(cookieValue) + if err != nil { + t.Fatalf("failed to marshal cookieValue: %v", err) + } + + // Template for setting cookies + const setCookieHTML = ` + +Set Cookie Test + +

Set Cookie Page

+ + +` + + // Template for getting cookies only + const getCookiesHTML = ` + +Get Cookies Test + +

Get Cookies Page

+

This page only displays cookies, it does not set any.

+

+ + +` + + setCookieTmpl := template.Must(template.New("set_cookie_page").Parse(setCookieHTML)) + var setCookieBuf bytes.Buffer + if err := setCookieTmpl.Execute(&setCookieBuf, map[string]interface{}{ + "NameJS": string(nameJS), + "ValueJS": string(valueJS), + }); err != nil { + t.Fatalf("failed to execute set cookie page template: %v", err) + } + + // Route that sets the cookie + mux.HandleFunc("/set-cookie", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, setCookieBuf.String()) + }) + + // Route that only displays cookies + mux.HandleFunc("/get-cookies", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = io.WriteString(w, getCookiesHTML) + }) + + ln, err := net.Listen("tcp", "0.0.0.0:0") + if err != nil { + t.Fatalf("failed to start cookie test server: %v", err) + } + srv := &http.Server{Handler: mux} + go func() { _ = srv.Serve(ln) }() + + // figure out the random port assigned + _, port, _ := net.SplitHostPort(ln.Addr().String()) + url = "http://127.0.0.1:" + port + stop = func() { + _ = srv.Shutdown(context.Background()) + } + return url, stop +} + +// navigateAndEnsureCookie opens the given URL and asserts that the page's #cookies +// element contains name=value. It is idempotent and used before/after restarts. +func navigateAndEnsureCookie(ctx context.Context, wsURL, url, cookieName, cookieValue string, label string) error { + logger := logctx.FromContext(ctx) + + // Run playwright script + cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "index.ts", + "navigate-and-ensure-cookie", + "--url", url, + "--cookie-name", cookieName, + "--cookie-value", cookieValue, + "--label", label, + "--ws-url", wsURL, + "--timeout", "45000", ) + cmd.Dir = getPlaywrightPath() + + output, err := cmd.CombinedOutput() + if err != nil { + logger.Info("[playwright]", "action", "navigate-and-ensure-cookie failed", "output", string(output)) + return fmt.Errorf("playwright navigate-and-ensure-cookie failed: %w, output: %s", err, string(output)) + } + + logger.Info("[playwright]", "action", "navigate-and-ensure-cookie success", "output", string(output)) + return nil } -func getCookieViaDevtools(ctx context.Context, wsURL, name string) (string, error) { - allocCtx, cancel := chromedp.NewRemoteAllocator(ctx, wsURL) - defer cancel() - cctx, cancel2 := chromedp.NewContext(allocCtx) - defer cancel2() - var cookies []*network.Cookie - url := "https://example.com" - err := chromedp.Run(cctx, - network.Enable(), - chromedp.ActionFunc(func(ctx context.Context) error { - cs, err := network.GetCookies().WithURLs([]string{url}).Do(ctx) - if err != nil { - return err - } - cookies = cs - return nil - }), +// setAndVerifyLocalStorage sets a localStorage key-value pair and verifies it was set correctly +func setAndVerifyLocalStorage(ctx context.Context, wsURL, url, key, value, label string) error { + logger := logctx.FromContext(ctx) + + // Run playwright script + cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "index.ts", + "set-localstorage", + "--url", url, + "--key", key, + "--value", value, + "--label", label, + "--ws-url", wsURL, + "--timeout", "45000", ) + cmd.Dir = getPlaywrightPath() + + output, err := cmd.CombinedOutput() if err != nil { - return "", err + logger.Info("[playwright]", "action", "set-localstorage failed", "output", string(output)) + return fmt.Errorf("playwright set-localstorage failed: %w, output: %s", err, string(output)) } - for _, c := range cookies { - if c.Name == name { - return c.Value, nil - } + + logger.Info("[playwright]", "action", "set-localstorage success", "output", string(output)) + return nil +} + +// verifyLocalStorage verifies that a localStorage key-value pair exists +func verifyLocalStorage(ctx context.Context, wsURL, url, key, value, label string) error { + logger := logctx.FromContext(ctx) + + // Run playwright script + cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "index.ts", + "verify-localstorage", + "--url", url, + "--key", key, + "--value", value, + "--label", label, + "--ws-url", wsURL, + "--timeout", "45000", + ) + cmd.Dir = getPlaywrightPath() + + output, err := cmd.CombinedOutput() + if err != nil { + logger.Info("[playwright]", "action", "verify-localstorage failed", "output", string(output)) + return fmt.Errorf("playwright verify-localstorage failed: %w, output: %s", err, string(output)) } - return "", fmt.Errorf("cookie %q not found", name) + + logger.Info("[playwright]", "action", "verify-localstorage success", "output", string(output)) + return nil +} + +// navigateToXAndVerifyCookie navigates to x.com and then to news.ycombinator.com to generate cookies, +// then verifies that the guest_id cookie was created for .x.com +func navigateToXAndVerifyCookie(ctx context.Context, wsURL string, label string) error { + logger := logctx.FromContext(ctx) + + // Run playwright script to navigate to x.com and back + cmd := exec.CommandContext(ctx, "pnpm", "exec", "tsx", "index.ts", + "navigate-to-x-and-back", + "--label", label, + "--ws-url", wsURL, + "--timeout", "45000", + ) + cmd.Dir = getPlaywrightPath() + + output, err := cmd.CombinedOutput() + if err != nil { + logger.Info("[playwright]", "action", "navigate-to-x-and-back failed", "output", string(output)) + return fmt.Errorf("playwright navigate-to-x-and-back failed: %w, output: %s", err, string(output)) + } + + logger.Info("[playwright]", "action", "navigate-to-x-and-back success", "output", string(output)) + + // Now verify the cookie was created by querying the database + logger.Info("[cookie-verify]", "action", "verifying guest_id cookie for .x.com") + + // wild: it takes about 10 seconds for cookies to flush to disk, and a supervisorctl stop / sigterm does not force it either. So we sleep + time.Sleep(15 * time.Second) + + // Execute SQLite query to check for the cookie + sqlQuery := `SELECT creation_utc,host_key,name,value,encrypted_value,last_update_utc FROM cookies WHERE host_key=".x.com" AND name="guest_id";` + + // Find the Cookies database file path + cookiesDBPath := "/home/kernel/user-data/Default/Cookies" + + stdout, err := execCombinedOutput(ctx, "sqlite3", []string{cookiesDBPath, "-header", "-column", sqlQuery}) + if err != nil { + return fmt.Errorf("failed to execute sqlite3 query on primary path: %w, output: %s", err, stdout) + } + + // Log the raw output for debugging + logger.Info("[cookie-verify]", "action", "sqlite3 output", "stdout", stdout) + + // Check if the output contains the expected cookie + if !strings.Contains(stdout, ".x.com") || !strings.Contains(stdout, "guest_id") { + logger.Error("[cookie-verify]", "action", "guest_id cookie not found", "output", stdout) + return fmt.Errorf("guest_id cookie for .x.com not found in database output: %s", stdout) + } + + logger.Info("[cookie-verify]", "action", "guest_id cookie verified successfully", "output", stdout) + return nil } func apiClient() (*instanceoapi.ClientWithResponses, error) { return instanceoapi.NewClientWithResponses(apiBaseURL, instanceoapi.WithHTTPClient(http.DefaultClient)) } +// RemoteExecError represents a non-zero exit from a remote exec, exposing exit code and combined output +type RemoteExecError struct { + Command string + Args []string + ExitCode int + Output string +} + +func (e *RemoteExecError) Error() string { + return fmt.Sprintf("remote exec %s exited with code %d", e.Command, e.ExitCode) +} + +// execCombinedOutput runs a command via the remote API and returns combined stdout+stderr and an error if exit code != 0 +func execCombinedOutput(ctx context.Context, command string, args []string) (string, error) { + client, err := apiClient() + if err != nil { + return "", err + } + + req := instanceoapi.ProcessExecJSONRequestBody{ + Command: command, + Args: &args, + } + + rsp, err := client.ProcessExecWithResponse(ctx, req) + if err != nil { + return "", err + } + if rsp.JSON200 == nil { + return "", fmt.Errorf("remote exec failed: %s body=%s", rsp.Status(), string(rsp.Body)) + } + + var stdout, stderr string + if rsp.JSON200.StdoutB64 != nil && *rsp.JSON200.StdoutB64 != "" { + if b, decErr := base64.StdEncoding.DecodeString(*rsp.JSON200.StdoutB64); decErr == nil { + stdout = string(b) + } + } + if rsp.JSON200.StderrB64 != nil && *rsp.JSON200.StderrB64 != "" { + if b, decErr := base64.StdEncoding.DecodeString(*rsp.JSON200.StderrB64); decErr == nil { + stderr = string(b) + } + } + combined := stdout + stderr + + exitCode := 0 + if rsp.JSON200.ExitCode != nil { + exitCode = *rsp.JSON200.ExitCode + } + if exitCode != 0 { + return combined, &RemoteExecError{Command: command, Args: args, ExitCode: exitCode, Output: combined} + } + return combined, nil +} + func downloadUserDataZip(ctx context.Context) ([]byte, error) { client, err := apiClient() if err != nil { return nil, err } params := &instanceoapi.DownloadDirZipParams{Path: "/home/kernel/user-data"} - rsp, err := client.DownloadDirZip(ctx, params) + rsp, err := client.DownloadDirZipWithResponse(ctx, params) if err != nil { return nil, err } - defer rsp.Body.Close() - return io.ReadAll(rsp.Body) + if rsp.StatusCode() != http.StatusOK { + return nil, fmt.Errorf("unexpected status downloading zip: %s body=%s", rsp.Status(), string(rsp.Body)) + } + return rsp.Body, nil } func uploadUserDataZip(ctx context.Context, zipBytes []byte) error { @@ -290,45 +744,31 @@ func uploadUserDataZip(ctx context.Context, zipBytes []byte) error { return err } -func restartChromiumViaAPI(ctx context.Context) error { - client, err := apiClient() - if err != nil { - return err - } - // Restart chromium service - req := instanceoapi.ProcessExecJSONRequestBody{ - Command: "supervisorctl", - Args: &[]string{"-c", "/etc/supervisor/supervisord.conf", "restart", "chromium"}, +func startChromiumViaAPI(ctx context.Context) error { + if out, err := execCombinedOutput(ctx, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "start", "chromium"}); err != nil { + return fmt.Errorf("failed to start chromium: %w, output: %s", err, out) } - _, err = client.ProcessExecWithResponse(ctx, req) - if err != nil { + // Ensure process fully running before proceeding + if err := waitForProgramStates(ctx, "chromium", []string{"RUNNING"}, 10*time.Second); err != nil { return err } return nil } -func waitSupervisorAvail(ctx context.Context) error { +func deleteDirectoryViaAPI(ctx context.Context, path string) error { client, err := apiClient() if err != nil { return err } - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - for { - req := instanceoapi.ProcessExecJSONRequestBody{ - Command: "supervisorctl", - Args: &[]string{"-c", "/etc/supervisor/supervisord.conf", "avail"}, - } - rsp, err := client.ProcessExecWithResponse(ctx, req) - if err == nil && rsp != nil && rsp.JSON200 != nil && rsp.JSON200.ExitCode != nil && *rsp.JSON200.ExitCode == 0 { - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-ticker.C: - } + body := instanceoapi.DeleteDirectoryJSONRequestBody{Path: path} + rsp, err := client.DeleteDirectoryWithResponse(ctx, body) + if err != nil { + return err + } + if rsp.StatusCode() != http.StatusOK { + return fmt.Errorf("unexpected status deleting directory: %s body=%s", rsp.Status(), string(rsp.Body)) } + return nil } func validateZip(b []byte) error { @@ -350,3 +790,349 @@ func validateZip(b []byte) error { rc.Close() return nil } + +type testLayout struct { + BaseDir string + ZipsDir string + RestoreDir string +} + +// createTestTempLayout creates .tmp/userdata-test-{timestamp}/ with subdirs for zips and the restored userdata directory (i.e. after saving and preparing for reuse) +func createTestTempLayout(t *testing.T) testLayout { + // Base under repo local .tmp + base := filepath.Join(".tmp", fmt.Sprintf("userdata-test-%d", time.Now().UnixNano())) + paths := []string{ + base, + filepath.Join(base, "zips"), + filepath.Join(base, "restored"), + } + for _, p := range paths { + if err := os.MkdirAll(p, 0700); err != nil { + t.Fatalf("create temp dir %s: %v", p, err) + } + } + return testLayout{ + BaseDir: base, + ZipsDir: filepath.Join(base, "zips"), + RestoreDir: filepath.Join(base, "restored"), + } +} + +// unzipBytesToDir extracts a zip archive (in-memory) into destDir +func unzipBytesToDir(b []byte, destDir string) error { + r, err := zip.NewReader(bytes.NewReader(b), int64(len(b))) + if err != nil { + return err + } + for _, f := range r.File { + // Sanitize name + name := filepath.Clean(f.Name) + if strings.HasPrefix(name, "..") { + return fmt.Errorf("invalid zip path: %s", f.Name) + } + abs := filepath.Join(destDir, name) + if f.FileInfo().IsDir() { + if err := os.MkdirAll(abs, 0755); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + w, err := os.OpenFile(abs, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, f.Mode()) + if err != nil { + rc.Close() + return err + } + if _, err := io.Copy(w, rc); err != nil { + w.Close() + rc.Close() + return err + } + w.Close() + rc.Close() + } + return nil +} + +// zipDirToBytes zips the contents of dir (no extra top-level folder) to bytes +func zipDirToBytes(dir string) ([]byte, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + defer zw.Close() + + // Walk dir + root := filepath.Clean(dir) + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if path == root { + return nil + } + rel, err := filepath.Rel(root, path) + if err != nil { + return err + } + rel = filepath.ToSlash(rel) + if info.IsDir() { + _, err := zw.Create(rel + "/") + return err + } + fh, err := zip.FileInfoHeader(info) + if err != nil { + return err + } + fh.Name = rel + fh.Method = zip.Deflate + w, err := zw.CreateHeader(fh) + if err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + _, copyErr := io.Copy(w, f) + f.Close() + return copyErr + }) + if err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// verifyCookieInLocalSnapshot verifies cookie presence in local unzipped snapshot +func verifyCookieInLocalSnapshot(ctx context.Context, root string, cookieName, wantValue string) error { + logger := logctx.FromContext(ctx) + candidates := []string{ + filepath.Join(root, "Default", "Network", "Cookies"), + filepath.Join(root, "Default", "Cookies"), + } + + logger.Info("[verify]", "action", "checking cookie database", "cookieName", cookieName, "wantValue", wantValue) + + for _, p := range candidates { + logger.Info("[verify]", "action", "checking database", "path", p) + ok, err := inspectLocalCookiesDB(ctx, p, cookieName, wantValue) + if err == nil && ok { + logger.Info("[verify]", "action", "cookie found", "path", p) + return nil + } + if err != nil { + logger.Warn("[verify]", "action", "database check failed", "path", p, "error", err) + } + } + return fmt.Errorf("cookie %q not found in local snapshot", cookieName) +} + +func inspectLocalCookiesDB(ctx context.Context, dbPath, cookieName, wantValue string) (bool, error) { + logger := logctx.FromContext(ctx) + + // If db does not exist, skip + if _, err := os.Stat(dbPath); err != nil { + logger.Info("[inspect]", "action", "database file not found", "path", dbPath, "error", err) + return false, nil + } + + logger.Info("[inspect]", "action", "opening database", "path", dbPath) + db, err := sql.Open("sqlite", dbPath+"?_pragma=query_only(1)&_pragma=journal_mode(wal)") + if err != nil { + logger.Warn("[inspect]", "action", "failed to open database", "path", dbPath, "error", err) + return false, err + } + defer db.Close() + + rows, err := db.QueryContext(ctx, "SELECT name, value, length(encrypted_value) FROM cookies") + if err != nil { + logger.Warn("[inspect]", "action", "failed to query cookies", "path", dbPath, "error", err) + return false, err + } + defer rows.Close() + + logger.Info("[inspect]", "action", "scanning cookies from database", "path", dbPath) + cookieCount := 0 + for rows.Next() { + var name, value string + var encLen int64 + if err := rows.Scan(&name, &value, &encLen); err != nil { + logger.Warn("[inspect]", "action", "failed to scan cookie row", "error", err) + continue + } + cookieCount++ + logger.Info("[inspect]", "action", "found cookie", "name", name, "value", value, "encrypted", encLen > 0) + + if name == cookieName { + if value == wantValue || encLen > 0 { + logger.Info("[inspect]", "action", "target cookie found", "name", name, "value", value, "encrypted", encLen > 0) + return true, nil + } + } + } + + logger.Info("[inspect]", "action", "database scan complete", "path", dbPath, "totalCookies", cookieCount) + return false, rows.Err() +} + +// deleteLocalSingletonLockFiles removes Chromium singleton files in a local snapshot +func deleteLocalSingletonLockFiles(root string) error { + for _, name := range []string{"SingletonLock", "SingletonCookie", "SingletonSocket", "RunningChromeVersion"} { + p := filepath.Join(root, name) + _ = os.Remove(p) + } + return nil +} + +// execCommandWithResponse is a helper function that executes a command via the remote API +// and handles the response parsing consistently across all callers +// Deprecated: use execCombinedOutput instead +func execCommandWithResponse(ctx context.Context, command string, args []string) (*instanceoapi.ProcessExecResponse, error) { + client, err := apiClient() + if err != nil { + return nil, err + } + + req := instanceoapi.ProcessExecJSONRequestBody{ + Command: command, + Args: &args, + } + + return client.ProcessExecWithResponse(ctx, req) +} + +// stopChromiumViaSupervisord stops chromium using supervisord via the remote API +func stopChromiumViaSupervisord(ctx context.Context) error { + logger := logctx.FromContext(ctx) + + // Wait a bit for any pending I/O to complete + logger.Info("[stop]", "action", "waiting for I/O flush", "seconds", 3) + time.Sleep(3 * time.Second) + + // Now use supervisorctl to ensure it's fully stopped + logger.Info("[stop]", "action", "stopping via supervisorctl") + if out, stopErr := execCombinedOutput(ctx, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "stop", "chromium"}); stopErr != nil { + return fmt.Errorf("failed to stop chromium via supervisorctl: %w, output: %s", stopErr, out) + } + + // Accept either STOPPED or EXITED as terminal stopped states + desiredStates := []string{"STOPPED", "EXITED"} + if waitErr := waitForProgramStates(ctx, "chromium", desiredStates, 5*time.Second); waitErr != nil { + return fmt.Errorf("chromium did not reach a stopped state: %w", waitErr) + } + + // Allow a short grace period for I/O flush + time.Sleep(1 * time.Second) + return nil +} + +// getProgramState returns the current supervisor state (e.g. RUNNING, STOPPED, EXITED) for the given program. +// It parses the output of `supervisorctl status` even if the command exits with a non-zero status code, which +// supervisorctl does when the target program is not in the RUNNING state. +func getProgramState(ctx context.Context, programName string) (string, error) { + stdout, err := execCombinedOutput(ctx, "supervisorctl", []string{"-c", "/etc/supervisor/supervisord.conf", "status", programName}) + if err != nil { + if execErr, ok := err.(*RemoteExecError); ok && execErr.ExitCode == 3 { + stdout = execErr.Output + } else { + return "", err + } + } + + // Expected output example: + // "chromium STOPPED Sep 21 10:05 AM" + // "chromium EXITED Sep 21 10:05 AM (exit status 0)" + fields := strings.Fields(stdout) + if len(fields) < 2 { + return "", fmt.Errorf("unexpected supervisorctl status output: %s", stdout) + } + return fields[1], nil +} + +// waitForProgramStates polls supervisorctl status until the program reaches any of the desired states +// or the timeout expires. +func waitForProgramStates(ctx context.Context, programName string, desiredStates []string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + + contains := func(list []string, s string) bool { + for _, v := range list { + if v == s { + return true + } + } + return false + } + + for { + state, err := getProgramState(ctx, programName) + if err == nil && contains(desiredStates, state) { + return nil + } + + if time.Now().After(deadline) { + if err != nil { + return err + } + return fmt.Errorf("timeout waiting for %s to reach states %v (last state %s)", programName, desiredStates, state) + } + time.Sleep(500 * time.Millisecond) + } +} + +// runCookieDebugScript executes the cookie debug script in the container to check file ownership and permissions +func runCookieDebugScript(ctx context.Context) error { + logger := logctx.FromContext(ctx) + + // Read the debug script content + scriptContent, err := os.ReadFile("cookie_debug.sh") + if err != nil { + return fmt.Errorf("failed to read debug script: %w", err) + } + + // Execute the script content directly via bash + args := []string{"-c", string(scriptContent)} + stdout, err := execCombinedOutput(ctx, "bash", args) + if err != nil { + return fmt.Errorf("failed to execute debug script: %w, output: %s", err, stdout) + } + + logger.Info("[diagnostic]", "action", "debug script output") + fmt.Println(stdout) + return nil +} + +// verifyCookieInContainerDB checks that the specified cookie exists in the cookies database on the container +func verifyCookieInContainerDB(ctx context.Context, cookieName string) error { + logger := logctx.FromContext(ctx) + + // Execute SQLite query to check for the cookie + sqlQuery := fmt.Sprintf(`SELECT creation_utc,host_key,name,value,encrypted_value,last_update_utc FROM cookies WHERE name="%s";`, cookieName) + + // Find the Cookies database file path + cookiesDBPath := "/home/kernel/user-data/Default/Cookies" + + stdout, err := execCombinedOutput(ctx, "sqlite3", []string{cookiesDBPath, "-header", "-column", sqlQuery}) + if err != nil { + return fmt.Errorf("failed to execute sqlite3 query: %w, output: %s", err, stdout) + } + + // Log the raw output for debugging + logger.Info("[container-cookie-verify]", "action", "sqlite3 output", "stdout", stdout) + + // Check if the output contains the expected cookie + if !strings.Contains(stdout, cookieName) { + logger.Error("[container-cookie-verify]", "action", "cookie not found", "cookieName", cookieName, "output", stdout) + return fmt.Errorf("cookie %q not found in container database output: %s", cookieName, stdout) + } + + logger.Info("[container-cookie-verify]", "action", "cookie verified successfully", "cookieName", cookieName, "output", stdout) + return nil +} diff --git a/server/e2e/playwright/.gitignore b/server/e2e/playwright/.gitignore new file mode 100644 index 00000000..4f6a127d --- /dev/null +++ b/server/e2e/playwright/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +dist/ +*.log +.pnpm-debug.log* +cookie-verify-*.png +screenshot*.png diff --git a/server/e2e/playwright/README.md b/server/e2e/playwright/README.md new file mode 100644 index 00000000..99af9a0c --- /dev/null +++ b/server/e2e/playwright/README.md @@ -0,0 +1,74 @@ +# Playwright CDP Integration + +This directory contains a Playwright-based script that replaces the chromedp functionality in the e2e tests. + +## Installation + +```bash +pnpm install +``` + +## Usage + +The script connects to an existing Chrome browser instance via CDP (Chrome DevTools Protocol) and performs various browser automation tasks. + +### Commands + +#### navigate-and-ensure-cookie + +Navigates to a URL and ensures a specific cookie exists with the expected value: + +```bash +pnpm exec tsx index.ts navigate-and-ensure-cookie \ + --url "http://localhost:8080/set-cookie" \ + --cookie-name "session_id" \ + --cookie-value "abc123" \ + --label "test-label" \ + --ws-url "ws://127.0.0.1:9222/" \ + --timeout 45000 +``` + +Options: +- `--url` (required): The URL to navigate to +- `--cookie-name` (required): The name of the cookie to check +- `--cookie-value` (required): The expected value of the cookie +- `--label` (optional): Label for screenshot filenames on failure +- `--ws-url` (optional): WebSocket URL for CDP connection (default: ws://127.0.0.1:9222/) +- `--timeout` (optional): Timeout in milliseconds (default: 45000) + +#### capture-screenshot + +Takes a full-page screenshot: + +```bash +pnpm exec tsx index.ts capture-screenshot \ + --filename "screenshot.png" \ + --ws-url "ws://127.0.0.1:9222/" +``` + +Options: +- `--filename` (required): Output filename for the screenshot +- `--ws-url` (optional): WebSocket URL for CDP connection (default: ws://127.0.0.1:9222/) + +## Integration with Go Tests + +The Go e2e tests execute this script via `exec.Command` to perform browser automation tasks. The script: + +1. Connects to an existing Chrome instance (not launching a new one) +2. Reuses existing browser contexts and pages when possible +3. Returns appropriate exit codes (0 for success, 1 for failure) +4. Outputs logs to stdout/stderr for debugging + +## Development + +To test the script locally: + +1. Start a Chrome instance with remote debugging enabled: + ```bash + chromium --remote-debugging-port=9222 + ``` + +2. Run the script with desired arguments: + ```bash + pnpm exec tsx index.ts [options] + ``` diff --git a/server/e2e/playwright/index.ts b/server/e2e/playwright/index.ts new file mode 100644 index 00000000..61de5f34 --- /dev/null +++ b/server/e2e/playwright/index.ts @@ -0,0 +1,411 @@ +#!/usr/bin/env tsx + +import { writeFileSync } from 'fs'; +import { Browser, BrowserContext, chromium, Page } from 'playwright-core'; + +interface CommandOptions { + wsURL?: string; + timeout?: number; +} + +interface NavigateCookieOptions extends CommandOptions { + url: string; + cookieName: string; + cookieValue: string; + label?: string; +} + +interface NavigateCookieFormOptions extends CommandOptions { + url: string; + cookieName: string; + cookieValue: string; + label?: string; +} + +interface LocalStorageOptions extends CommandOptions { + url: string; + key: string; + value: string; + label?: string; +} + +interface HistoryOptions extends CommandOptions { + urls: string[]; + label?: string; +} + +interface NavigateXAndBackOptions extends CommandOptions { + label?: string; +} + +interface ScreenshotOptions extends CommandOptions { + filename: string; +} + +class CDPClient { + private browser?: Browser; + private context?: BrowserContext; + private page?: Page; + + async connect(wsURL: string = 'ws://127.0.0.1:9222/'): Promise { + try { + // Connect to existing browser via CDP + this.browser = await chromium.connectOverCDP(wsURL); + + // Get the default context (or first available context) + const contexts = this.browser.contexts(); + if (contexts.length > 0) { + this.context = contexts[0]; + } else { + // This shouldn't happen with an existing browser, but just in case + this.context = await this.browser.newContext(); + } + + // Get existing page or create new one + const pages = this.context.pages(); + if (pages.length > 0) { + this.page = pages[0]; + } else { + this.page = await this.context.newPage(); + } + } catch (error) { + console.error('Failed to connect to browser:', error); + throw error; + } + } + + async navigateAndEnsureCookie(options: NavigateCookieOptions): Promise { + if (!this.page) throw new Error('Not connected to browser'); + + const { url, cookieName, cookieValue, label = 'check', timeout = 45000 } = options; + + // Array to collect browser console logs + const browserLogs: string[] = []; + // Handler to push logs from browser console + const consoleListener = (msg: any) => { + // Only log 'log', 'warn', 'error', 'info' types + if (['log', 'warn', 'error', 'info'].includes(msg.type())) { + // Join all arguments as string + const text = msg.text(); + browserLogs.push(`[browser][${msg.type()}] ${text}`); + } + }; + + try { + console.log(`[cdp] action: navigate-cookie, url: ${url}, label: ${label}`); + + // Attach console listener + this.page.on('console', consoleListener); + + // Set timeout for this operation + this.page.setDefaultTimeout(timeout); + + // Navigate to the URL + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + + // Wait for #cookies element to be visible + await this.page.waitForSelector('#cookies', { state: 'visible', timeout: 5000 }); + + // Get the text content of #cookies element + const cookiesText = await this.page.textContent('#cookies'); + + // Echo browser console logs + if (browserLogs.length > 0) { + for (const log of browserLogs) { + console.log(log); + } + } + + if (!cookiesText) { + throw new Error('#cookies element has no text content'); + } + + // Check if the cookie exists with the expected value + const expectedCookie = `${cookieName}=${cookieValue}`; + if (!cookiesText.includes(expectedCookie)) { + // Take a screenshot on failure + const screenshotPath = `cookie-verify-miss-${label}.png`; + await this.captureScreenshot({ filename: screenshotPath }); + throw new Error(`Expected document.cookie to contain "${expectedCookie}", got "${cookiesText}"`); + } + + console.log(`Cookie verified successfully: ${cookieName}=${cookieValue}`); + + } catch (error) { + // Echo browser console logs on error as well + if (browserLogs.length > 0) { + for (const log of browserLogs) { + console.log(log); + } + } + // Take a screenshot on any error + const screenshotPath = `cookie-verify-${label}.png`; + await this.captureScreenshot({ filename: screenshotPath }).catch(console.error); + throw error; + } finally { + // Remove the console listener to avoid leaks + if (this.page) { + this.page.off('console', consoleListener); + } + } + } + + async setAndVerifyLocalStorage(options: LocalStorageOptions): Promise { + if (!this.page) throw new Error('Not connected to browser'); + + const { url, key, value, label = 'localstorage', timeout = 45000 } = options; + + try { + console.log(`[cdp] action: set-localstorage, url: ${url}, key: ${key}, value: ${value}, label: ${label}`); + + // Set timeout for this operation + this.page.setDefaultTimeout(timeout); + + // Navigate to the URL + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + + // Set localStorage value + await this.page.evaluate(({ k, v }: { k: string; v: string }) => { + (globalThis as any).localStorage.setItem(k, v); + console.log(`[localStorage] Set ${k}=${v}`); + }, { k: key, v: value }); + + // Verify localStorage value + const storedValue = await this.page.evaluate(({ k }: { k: string }) => { + return (globalThis as any).localStorage.getItem(k); + }, { k: key }); + + if (storedValue !== value) { + const screenshotPath = `localstorage-verify-miss-${label}.png`; + await this.captureScreenshot({ filename: screenshotPath }); + throw new Error(`Expected localStorage["${key}"] to be "${value}", got "${storedValue}"`); + } + + console.log(`LocalStorage verified successfully: ${key}=${value}`); + + // Navigate to google.com to potentially force a flush + console.log('[cdp] action: navigating to google.com to force localStorage flush'); + try { + await this.page.goto('https://www.google.com', { waitUntil: 'domcontentloaded' }); + console.log('[cdp] action: google.com navigation completed'); + } catch (navError) { + console.warn('[cdp] action: google.com navigation failed, continuing anyway:', navError); + } + } catch (error) { + const screenshotPath = `localstorage-verify-${label}.png`; + await this.captureScreenshot({ filename: screenshotPath }).catch(console.error); + throw error; + } + } + + async verifyLocalStorage(options: LocalStorageOptions): Promise { + if (!this.page) throw new Error('Not connected to browser'); + + const { url, key, value, label = 'localstorage-verify', timeout = 45000 } = options; + + try { + console.log(`[cdp] action: verify-localstorage, url: ${url}, key: ${key}, expected: ${value}, label: ${label}`); + + // Set timeout for this operation + this.page.setDefaultTimeout(timeout); + + // Navigate to the URL + await this.page.goto(url, { waitUntil: 'domcontentloaded' }); + + // Get localStorage value + const storedValue = await this.page.evaluate(({ k }: { k: string }) => { + return (globalThis as any).localStorage.getItem(k); + }, { k: key }); + + if (storedValue !== value) { + const screenshotPath = `localstorage-verify-fail-${label}.png`; + await this.captureScreenshot({ filename: screenshotPath }); + throw new Error(`Expected localStorage["${key}"] to be "${value}", got "${storedValue}"`); + } + + console.log(`LocalStorage verification successful: ${key}=${value}`); + } catch (error) { + const screenshotPath = `localstorage-verify-fail-${label}.png`; + await this.captureScreenshot({ filename: screenshotPath }).catch(console.error); + throw error; + } + } + + async navigateToXAndBack(options: NavigateXAndBackOptions): Promise { + if (!this.page) throw new Error('Not connected to browser'); + + const { label = 'x-navigation', timeout = 45000 } = options; + + try { + console.log(`[cdp] action: navigate-to-x-and-back, label: ${label}`); + + // Set timeout for this operation + this.page.setDefaultTimeout(timeout); + + // Do the navigation to x.com and back twice in a loop + for (let i = 0; i < 2; i++) { + console.log(`[cdp] action: [${i + 1}/2] navigating to x.com`); + await this.page.goto('https://x.com', { waitUntil: 'domcontentloaded' }); + + // Wait a bit to ensure cookies are set + await this.page.waitForTimeout(2000); + + console.log(`[cdp] action: [${i + 1}/2] navigating to news.ycombinator.com`); + await this.page.goto('https://news.ycombinator.com', { waitUntil: 'domcontentloaded' }); + + // Wait a bit to ensure the navigation is recorded + await this.page.waitForTimeout(2000); + } + + console.log('X.com navigation and return completed successfully'); + + // Use Playwright to read all cookies and log them + //await this.context.close(); + + } catch (error) { + const screenshotPath = `x-navigation-${label}.png`; + await this.captureScreenshot({ filename: screenshotPath }).catch(console.error); + throw error; + } + } + + async captureScreenshot(options: ScreenshotOptions): Promise { + if (!this.page) throw new Error('Not connected to browser'); + + const { filename } = options; + + try { + // Take a full page screenshot + const screenshot = await this.page.screenshot({ + fullPage: true, + type: 'png', + }); + + // Write to file + writeFileSync(filename, screenshot); + console.log(`Screenshot saved to: ${filename}`); + } catch (error) { + console.error('Failed to capture screenshot:', error); + throw error; + } + } + + async disconnect(): Promise { + // Note: We don't close the browser since it's an existing instance + // We just disconnect from it + if (this.browser) { + await this.browser.close().catch(() => { + // Ignore errors when disconnecting + }); + } + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + + if (args.length === 0) { + console.error('Usage: tsx index.ts [options]'); + console.error('Commands:'); + console.error(' navigate-and-ensure-cookie --url --cookie-name --cookie-value [--label