diff --git a/acceptance/bin/free_port.py b/acceptance/bin/free_port.py new file mode 100755 index 00000000000..9f6b86617fd --- /dev/null +++ b/acceptance/bin/free_port.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" +Print space-separated free TCP ports on the loopback interface. +Usage: free_port.py [count] +""" + +import socket +import sys + + +def main(): + count = int(sys.argv[1]) if len(sys.argv) > 1 else 1 + + # Keep every socket bound until all ports are picked, otherwise the kernel + # is free to hand out the same port twice. + sockets = [socket.socket() for _ in range(count)] + for s in sockets: + s.bind(("127.0.0.1", 0)) + + print(" ".join(str(s.getsockname()[1]) for s in sockets)) + + for s in sockets: + s.close() + + +if __name__ == "__main__": + main() diff --git a/acceptance/cmd/workspace/apps/run-local-node/script b/acceptance/cmd/workspace/apps/run-local-node/script index 15975835afc..3701e6fd697 100755 --- a/acceptance/cmd/workspace/apps/run-local-node/script +++ b/acceptance/cmd/workspace/apps/run-local-node/script @@ -4,14 +4,7 @@ cd app # so we don't need to start it in background. It will install the dependencies as part of the command trace $CLI apps run-local --prepare-environment --entry-point test.yml 2>&1 | grep -w "Hello, world" -# Function to find a free port in safe range -get_free_port() { - python3 -c "import socket,random; port=random.randint(49152,65535); s=socket.socket(); s.bind(('',port)); print(port); s.close()" 2>/dev/null || python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()" -} - -PORT=$(get_free_port) -DEBUG_PORT=$(get_free_port) -PROXY_PORT=$(get_free_port) +read -r PORT DEBUG_PORT PROXY_PORT <<< "$(free_port.py 3)" cleanup() { # Kill any still running processes on these ports when the script exits diff --git a/acceptance/cmd/workspace/apps/run-local/app/app.py b/acceptance/cmd/workspace/apps/run-local/app/app.py index cfff4ce7aef..ff304318d83 100644 --- a/acceptance/cmd/workspace/apps/run-local/app/app.py +++ b/acceptance/cmd/workspace/apps/run-local/app/app.py @@ -1,18 +1,35 @@ +import http.server +import json import os -import signal -from flask import Flask, request +import threading -app = Flask(__name__) +# Standard library only: acceptance tests run with the network disabled, so the +# app cannot install anything from PyPI. -app.logger.warning("Python Flask app has started with: " + os.environ.get("TEST")) +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + shutdown = self.path == "/shutdown" + body = b"" if shutdown else json.dumps(dict(self.headers), sort_keys=True).encode() + b"\n" -@app.route("/") -def index(): - return dict(request.headers) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + # shutdown() blocks until serve_forever() returns, which cannot happen until this + # handler returns, so it has to run on another thread. Stopping the loop rather + # than exiting the process lets the connection close cleanly, otherwise the client + # can see a reset instead of the response we just wrote. + if shutdown: + threading.Thread(target=self.server.shutdown, daemon=True).start() -@app.route("/shutdown") -def shutdown(): - os._exit(signal.SIGTERM) - return "Shutting down..." + def log_message(self, fmt, *args): + pass + + +# Bind before printing, so the message can never appear while the port is still closed. +server = http.server.HTTPServer(("127.0.0.1", int(os.environ["DATABRICKS_APP_PORT"])), Handler) +print("Python app has started with: " + os.environ["TEST"], flush=True) +server.serve_forever() diff --git a/acceptance/cmd/workspace/apps/run-local/app/app.yml b/acceptance/cmd/workspace/apps/run-local/app/app.yml index 0f6875f5b8a..3feee53ad5b 100644 --- a/acceptance/cmd/workspace/apps/run-local/app/app.yml +++ b/acceptance/cmd/workspace/apps/run-local/app/app.yml @@ -1,6 +1,6 @@ command: - - flask - - run + - python + - app.py env: - name: TEST diff --git a/acceptance/cmd/workspace/apps/run-local/app/requirements.txt b/acceptance/cmd/workspace/apps/run-local/app/requirements.txt deleted file mode 100644 index af266d21cd3..00000000000 --- a/acceptance/cmd/workspace/apps/run-local/app/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -Flask==3.1.3 diff --git a/acceptance/cmd/workspace/apps/run-local/out.test.toml b/acceptance/cmd/workspace/apps/run-local/out.test.toml index a013748b1ff..e90b6d5d1ba 100644 --- a/acceptance/cmd/workspace/apps/run-local/out.test.toml +++ b/acceptance/cmd/workspace/apps/run-local/out.test.toml @@ -1,3 +1,3 @@ -Local = false +Local = true Cloud = false -EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform"] +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] diff --git a/acceptance/cmd/workspace/apps/run-local/output.txt b/acceptance/cmd/workspace/apps/run-local/output.txt index 6b7b6243600..4c8b525b8cc 100644 --- a/acceptance/cmd/workspace/apps/run-local/output.txt +++ b/acceptance/cmd/workspace/apps/run-local/output.txt @@ -1,24 +1,34 @@ +=== Env var with valueFrom cannot be resolved locally + >>> errcode [CLI] apps run-local --entry-point value-from.yml Error: VALUE_FROM defined in value-from.yml with valueFrom property and can't be resolved locally. Please set VALUE_FROM environment variable in your terminal or using --env flag Exit code: 1 -Running command: uv run python -c print('Hello, world') + +=== Entry point overrides the command from app.yml + +>>> grep -x Running command: python -c print('Hello, world') ../LOG.entry-point +Running command: python -c print('Hello, world') + +>>> grep -x Hello, world ../LOG.entry-point Hello, world -=== Starting the app in background... -=== Waiting -=== Checking app is running... -=== Checking the proxy is running... ->>> curl -s -o - http://127.0.0.1:$(port) -{"Accept":"*/*","Accept-Encoding":"gzip","Host":"127.0.0.1:$(port)","User-Agent":"curl/(version)","X-Forwarded-Email":"[USERNAME]","X-Forwarded-Host":"localhost","X-Forwarded-Preferred-Username":"","X-Forwarded-User":"[USERNAME]","X-Real-Ip":"127.0.0.1","X-Request-Id":"[UUID]"} +=== Proxy forwards to the app and injects the identity headers + +>>> retry --until X-Forwarded-Email curl -sS http://127.0.0.1:[PROXY_PORT] +{"Accept": "*/*", "Accept-Encoding": "gzip", "Host": "127.0.0.1:[APP_PORT]", "User-Agent": "curl/(version)", "X-Forwarded-Email": "[USERNAME]", "X-Forwarded-Host": "localhost", "X-Forwarded-Preferred-Username": "", "X-Forwarded-User": "[USERNAME]", "X-Real-Ip": "127.0.0.1", "X-Request-Id": "[UUID]"} + +=== Proxy URL is reported on the port that was asked for + +>>> grep -x To access your app go to http://localhost:[PROXY_PORT] LOG.run +To access your app go to http://localhost:[PROXY_PORT] + +=== App picked up the env var from app.yml -=== Sending shutdown request... ->>> curl -s -o /dev/null http://127.0.0.1:$(port)/shutdown +>>> grep -x Python app has started with: test LOG.run +Python app has started with: test -=== Checking CLI command output... ->>> grep To debug your app, attach a debugger to port ./out.run.txt -To debug your app, attach a debugger to port $(debug_port) +=== Shutting the app down ->>> grep -o Python Flask app has started with: test ./out.run.txt -Python Flask app has started with: test +>>> curl -sS -o /dev/null http://127.0.0.1:[PROXY_PORT]/shutdown diff --git a/acceptance/cmd/workspace/apps/run-local/script b/acceptance/cmd/workspace/apps/run-local/script index 701c8f5e95f..2f293db29bb 100755 --- a/acceptance/cmd/workspace/apps/run-local/script +++ b/acceptance/cmd/workspace/apps/run-local/script @@ -1,75 +1,46 @@ cd app -trace errcode $CLI apps run-local --entry-point value-from.yml 2>&1 +# Ports are allocated per run so that tests in parallel worktrees do not collide. +read -r APP_PORT PROXY_PORT ENTRY_POINT_PORT <<< "$(free_port.py 3)" +add_repl.py "$APP_PORT" APP_PORT +add_repl.py "$PROXY_PORT" PROXY_PORT + +# The app and the proxy start asynchronously; allow up to 15s for them to come up. +export RETRY_MAX_ATTEMPTS=30 -# We first run the command with different entry point which starts unblocking script -# so we don't need to start it in background. It will install the dependencies as part of the command -trace $CLI apps run-local --prepare-environment --entry-point test.yml 2>&1 | grep -w "Hello, world" +title "Env var with valueFrom cannot be resolved locally\n" +trace errcode $CLI apps run-local --entry-point value-from.yml 2>&1 -PORT=8090 -DEBUG_PORT=5252 -PROXY_PORT=8091 +title "Entry point overrides the command from app.yml\n" +trace $CLI apps run-local --entry-point test.yml --port "$ENTRY_POINT_PORT" &> ../LOG.entry-point +trace grep -x "Running command: python -c print('Hello, world')" ../LOG.entry-point +trace grep -x "Hello, world" ../LOG.entry-point cleanup() { - # Kill any still running processes on these ports when the script exits - kill_port.py $PORT $DEBUG_PORT $PROXY_PORT + local status=$? + # Reap by port rather than by $PID: backgrounding a function puts a subshell between the + # script and the CLI, so $PID is that subshell and the CLI and the app it started are + # grandchildren that survive killing it. Windows reaches the same state for another + # reason: kill terminates the CLI outright rather than letting it stop the app. Only when + # the script failed: on success both have exited through /shutdown already and the ports + # may belong to another test by now. + [ "$status" -eq 0 ] || kill_port.py "$APP_PORT" "$PROXY_PORT" } -trap cleanup EXIT - -title "Starting the app in background..." -trace $CLI apps run-local --prepare-environment --debug --port "$PROXY_PORT" --debug-port "$DEBUG_PORT" --app-port "$PORT" > ../out.run.txt 2>&1 & +trace $CLI apps run-local --port "$PROXY_PORT" --app-port "$APP_PORT" &> ../LOG.run & PID=$! -# Ensure background process is killed on script exit -trap 'kill $PID 2>/dev/null || true' EXIT +trap cleanup EXIT cd .. -title Waiting for the app to start... -# Use a loop to check for the startup message instead of tail/sed which can be unreliable on Windows -# due to file locking, buffering issues, and different text processing behavior across Windows versions. -# A simple grep loop is more robust across platforms. -while [ -z "$(grep -o "Python Flask app has started with" out.run.txt 2>/dev/null)" ]; do - sleep 1 -done - -# Make sure the proxy is ready to serve requests -while [ -z "$(grep -o "To access your app go to " out.run.txt 2>/dev/null)" ]; do - sleep 1 -done - -title "Checking app is running..." -# Wait for the proxy to be ready to serve requests (with timeout) -timeout=5 -counter=0 -while ! curl -s -o /dev/null http://127.0.0.1:$PORT 2>/dev/null; do - sleep 1 - counter=$((counter + 1)) - if [ $counter -ge $timeout ]; then - echo "Timeout waiting for app to be ready after ${timeout}s" - exit 1 - fi -done - -title "Checking the proxy is running..." -# Wait for the proxy to be ready to serve requests (with timeout) -timeout=5 -counter=0 -while ! curl -s -o /dev/null http://127.0.0.1:$PROXY_PORT 2>/dev/null; do - sleep 1 - counter=$((counter + 1)) - if [ $counter -ge $timeout ]; then - echo "Timeout waiting for proxy to be ready after ${timeout}s" - exit 1 - fi -done - -trace curl -s -o - http://127.0.0.1:$PROXY_PORT +title "Proxy forwards to the app and injects the identity headers\n" +trace retry --until X-Forwarded-Email curl -sS "http://127.0.0.1:$PROXY_PORT" -title "Sending shutdown request..." -trace curl -s -o /dev/null http://127.0.0.1:$PROXY_PORT/shutdown || true +title "Proxy URL is reported on the port that was asked for\n" +trace grep -x "To access your app go to http://localhost:$PROXY_PORT" LOG.run -title "Checking CLI command output..." +title "App picked up the env var from app.yml\n" +trace grep -x "Python app has started with: test" LOG.run -trace grep "To debug your app, attach a debugger to port" ./out.run.txt -trace grep -o "Python Flask app has started with: test" ./out.run.txt -rm out.run.txt +title "Shutting the app down\n" +trace curl -sS -o /dev/null "http://127.0.0.1:$PROXY_PORT/shutdown" +wait $PID diff --git a/acceptance/cmd/workspace/apps/run-local/test.toml b/acceptance/cmd/workspace/apps/run-local/test.toml index fedbe4a7e03..82bff478304 100644 --- a/acceptance/cmd/workspace/apps/run-local/test.toml +++ b/acceptance/cmd/workspace/apps/run-local/test.toml @@ -1,27 +1,12 @@ Cloud = false -# This test uses fixed ports, so incompatible with parellel testing in different worktrees. -# It's also very slow when it does work. -Local = false +Local = true RecordRequests = false -Timeout = '2m' -TimeoutWindows = '10m' -Ignore = [ - '.venv', - '__pycache__' -] +# The command is unrelated to bundle deployment, so run it once rather than per engine. +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["direct"] + +# The ports are registered as replacements by the script, which is where they are allocated. [[Repls]] Old='curl/[0-9]+\.[0-9]+\.[0-9]+' New='curl/(version)' - -[[Repls]] -Old='127.0.0.1:[0-9]+' -New='127.0.0.1:$(port)' - -[[Repls]] -Old='To debug your app, attach a debugger to port [0-9]+' -New='To debug your app, attach a debugger to port $(debug_port)' - -[EnvMatrix] - DATABRICKS_BUNDLE_ENGINE = ["terraform"]