diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 18113f54f9b..234fbde217e 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -97,8 +97,10 @@ jobs: # tests/unit/test_agent_instructions.py and tests/unit/skills read CLAUDE.md, # the Copilot instructions, .claude/guardrails.md, agentic/docs/project-guide.md, # the skills and the commands (#12493 changed only those, skipped this job, and - # left main red). - CONFIG_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^(pyproject\.toml|pytest\.ini|conftest\.py|uv\.lock|package\.json|package-lock\.json|Project\.toml|Manifest\.toml|\.python-version|CLAUDE\.md|AGENTS\.md|\.github/copilot-instructions\.md|\.claude/guardrails\.md|agentic/docs/project-guide\.md)$|^(\.github/workflows/|\.github/actions/|prompts/|\.claude/skills/|agentic/commands/)' || true) + # left main red), and tests/unit/test_cloudbuild_configs.py and + # tests/unit/agents/test_image.py read the Cloud Build configs and the renderer's + # deploy smoke script. + CONFIG_CHANGES=$(echo "$CHANGED_FILES" | grep -E '^(pyproject\.toml|pytest\.ini|conftest\.py|uv\.lock|package\.json|package-lock\.json|Project\.toml|Manifest\.toml|\.python-version|CLAUDE\.md|AGENTS\.md|\.github/copilot-instructions\.md|\.claude/guardrails\.md|agentic/docs/project-guide\.md|([^/]+/)+cloudbuild\.yaml|agents/renderer/deploy_smoke\.sh)$|^(\.github/workflows/|\.github/actions/|prompts/|\.claude/skills/|agentic/commands/)' || true) # Force run on workflow_dispatch with force_run=true if [[ "${{ github.event_name }}" == "workflow_dispatch" && "${{ inputs.force_run }}" == "true" ]]; then diff --git a/agents/README.md b/agents/README.md index 05651892aa1..703f580622d 100644 --- a/agents/README.md +++ b/agents/README.md @@ -335,7 +335,7 @@ The image's `fonts` stage installs Debian's `fonts-noto-core` and `fonts-noto-cj Since 2026-10-30 the toolchain image also carries the JavaScript row's toolchain (JS-1 of step 3 of `agentic/specs/261024-agent-all-libraries.md`; the agents service sends JavaScript jobs since JS-4a, 2026-10-30): Node 22 under `/opt/node`, which the row runs by its path and the sandbox's `PATH` never carries; the repository's `package-lock.json` installed with `npm ci --ignore-scripts` under `/opt/anyplot/node_modules`; Playwright's Chromium headless shell alone under `/opt/pw-browsers`; the catalogue's harness, `automation/js-render/render.mjs`, under `/opt/anyplot/js-render/` with the import allowlist of MUI X snippets that the build derives from the installed packages' exports maps (`import-allowlist.json`); and Liberation Sans, Serif and Mono under `/opt/anyplot/js-fonts`, which only the row's `FONTCONFIG_FILE` (`renderer/toolchain/js-fonts.conf`) names, so Python's renders keep their fonts. Its build renders a Chart.js chart and a MUI X component through the harness with the row's environment, the CI job renders a catalogue file of each, and the deploy's candidate smoke renders a Chart.js chart from a typed table and, since JS-4a, refuses the promotion when it fails twice. Since JS-2 (2026-10-30) the row's environment switches on the harness's sandbox mode (`ANYPLOT_SANDBOX=1`, `ANYPLOT_NO_HTML=1`, `ANYPLOT_IMPORT_ALLOWLIST`): every request and WebSocket of the page refused, no popup, no WebRTC, a content security policy, UTC, a 1 GiB V8 heap for the page, MUI X's imports closed to the allowlist, a bounded console and one final stderr line `render: error: :: `; a JavaScript job may carry the table as typed JSON (`data_json`, from `anyplot/data/typed_json.py`), which the harness injects as `window.ANYPLOT_DATA`. The CI job checks the injection, the route and the import plugin in the built image. Since JS-4b (2026-10-31) `POST /page` runs the same row with `ANYPLOT_PAGE_ONLY=1`: the harness writes the page a render would screenshot and starts no browser, and the agents service wraps it into the downloadable `plot.html`. The CI job builds such a page for Chart.js and, since JS-4c (2026-10-31), for MUI X, whose page is a React bundle, wraps each and opens it from `file://` in the image's Chromium with `renderer/page_probe.mjs`: the chart draws and answers a hover, the frame's origin is `null`, and the plot code's attempts to navigate the window or its own frame, open a popup, fetch, or read storage and cookies all fail. Since JS-4b-2 (2026-10-31) the chat shows that page in the result card (`.interactive()`) through the API's relay page, `GET /preview/relay` (`api/preview_relay.py`): a static page of opaque origin that only the site's origins may frame, that tells its parent it is ready with a constant signal without content, and that writes the first page its parent then posts into a sandboxed `srcdoc` frame. The CI job's next step runs the probe's relay mode on the same wrapper: the relay with its real headers on an origin of its own, framed and scaled by a parent at an allowed origin that posts only on the relay's signal, shows the plot, which draws and answers a hover; the plot code cannot navigate the wrapper or the relay, a second page and a page posted by the plot, a sibling frame or another origin are ignored, the plot's own ready signal starts no post, another origin cannot frame the relay, and a listening 404 page at the relay's URL receives nothing. -`renderer/cloudbuild.yaml` builds, deploys a `candidate` revision without traffic, smokes it (IAM refuses an anonymous caller; with `_SMOKE_RENDER=true`, `/status` reports the launcher and the volume and a real matplotlib render goes through the candidate, with a token minted for the service URL), and promotes it. The first build also creates the service; the deploy adds the service URL to `RENDERER_AUDIENCES` itself, and `_MIN_INSTANCES=1` keeps an instance warm. CI builds the same image before merge and renders a seaborn, a plotnine, an altair, a pygal and a lets-plot plot through the harness in it (`.github/workflows/ci-image.yml`). The deploy flags and the owner tasks are in [Agent network design](../docs/concepts/agent-network.md#serving-and-infrastructure). +`renderer/cloudbuild.yaml` builds, deploys a `candidate` revision without traffic, smokes it (IAM refuses an anonymous caller; with `_SMOKE_RENDER=true`, `/status` reports the launcher and the volume, and a matplotlib, a Makie, a Chart.js and a plotly render and a data run go through the candidate with a token minted for the service URL, each one a gate; these render probes live in `renderer/deploy_smoke.sh`, because Cloud Build refuses a step argument over 10,000 characters), and promotes it. The first build also creates the service; the deploy adds the service URL to `RENDERER_AUDIENCES` itself, and `_MIN_INSTANCES=1` keeps an instance warm. CI builds the same image before merge and renders a seaborn, a plotnine, an altair, a pygal and a lets-plot plot through the harness in it (`.github/workflows/ci-image.yml`). The deploy flags and the owner tasks are in [Agent network design](../docs/concepts/agent-network.md#serving-and-infrastructure). ### Test diff --git a/agents/renderer/cloudbuild.yaml b/agents/renderer/cloudbuild.yaml index 14f9398abfe..ed5935cad59 100644 --- a/agents/renderer/cloudbuild.yaml +++ b/agents/renderer/cloudbuild.yaml @@ -272,133 +272,11 @@ steps: curl -fsS --retry 5 --retry-delay 5 --retry-all-errors -H "Authorization: Bearer $$TOKEN" -o status.json "$$URL/status" \ || { echo "/status failed with this build's token: check _ALLOWED_CALLERS, roles/run.invoker and RENDERER_AUDIENCES ($$SERVICE_URL)"; exit 1; } python3 -c "import json; s=json.load(open('status.json')); assert s['name']=='anyplot-renderer' and s['sandbox'] and s['volume'] is True, s; print('OK: /status', s['version'], s['mem_available_mb'], 'MiB available, volume in place')" - python3 - <<'EOF' - import json - source = "\n".join([ - "import matplotlib.pyplot as plt", - "fig, ax = plt.subplots(figsize=(16, 9), dpi=200)", - "ax.plot([1, 2, 3, 4], [3, 1, 4, 2], linewidth=6)", - "fig.savefig('plot-light.png')", - ]) - job = {"job_id": "smoke", "language": "python", "library": "matplotlib", "source": source, - "data_csv": "x\n1\n", "themes": ["light"], "timeout_s": 60} - json.dump(job, open("job.json", "w")) - EOF - curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 200 -H "Authorization: Bearer $$TOKEN" \ - -H "Content-Type: application/json" --data @job.json -o render.json "$$URL/render" \ - || { echo "the smoke render request failed"; exit 1; } - python3 -c "import json; r=json.load(open('render.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s')}; print('OK: smoke render', o['wall_s'], 's,', o['attempts'], 'attempt(s)')" - # 2b. A Makie render through the Julia row (step 5 of - # agentic/specs/261024-agent-all-libraries.md): the depot's package images - # load under gVisor with --compiled-modules=strict and the preloaded exec - # filter, or the render fails within seconds and nothing is promoted. The - # first Julia render of a fresh instance, so its wall time is the cold one. - python3 - <<'EOF' - import json - source = "\n".join([ - "using CairoMakie", - "THEME = get(ENV, \"ANYPLOT_THEME\", \"light\")", - "fig = Figure(size = (1600, 900))", - "ax = Axis(fig[1, 1], title = \"Größe – smoke\")", - "lines!(ax, [1, 2, 3, 4], [3, 1, 4, 2], linewidth = 6)", - "save(\"plot-$$(THEME).png\", fig; px_per_unit = 2)", - ]) - job = {"job_id": "smoke-makie", "language": "julia", "library": "makie", "source": source, - "data_csv": "x\n1\n", "themes": ["light"], "timeout_s": 90} - json.dump(job, open("makie_job.json", "w")) - EOF - curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 240 -H "Authorization: Bearer $$TOKEN" \ - -H "Content-Type: application/json" --data @makie_job.json -o makie.json "$$URL/render" \ - || { echo "the Makie smoke render request failed"; exit 1; } - python3 -c "import json; r=json.load(open('makie.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s','stderr_tail')}; print('OK: Makie smoke render', o['wall_s'], 's wall,', o['cpu_s'], 's CPU,', o['max_rss_mb'], 'MiB,', o['attempts'], 'attempt(s)')" - # 2c. A Chart.js render through the JavaScript row (JS-1 of step 3): Node by its path - # starts Chromium's headless shell under gVisor, which no local run can, and the - # page reads the job's typed table (`data_json`, `window.ANYPLOT_DATA`), as every - # agent job does. A gate like the Makie render since JS-4a served JavaScript: a - # failure refuses the promotion, and the revision serving now keeps serving. curl's - # retries cover the transport only, and a render that fails answers 200, so the - # render gets a second try 30 s later; each failure prints the exit code, the - # reason, the attempts and the stderr tail (JS renders failed on Cloud Run for about - # 40 minutes after a fresh toolchain build on 2026-10-30, cause unproven). - python3 - <<'EOF' - import json - source = "\n".join([ - "const t = window.ANYPLOT_TOKENS;", - "const data = window.ANYPLOT_DATA;", - "const canvas = document.createElement('canvas');", - "document.getElementById('container').appendChild(canvas);", - "new Chart(canvas, {", - " type: 'bar',", - " data: {labels: data.map((d) => d.Gruppe), datasets: [{data: data.map((d) => d.Wert), backgroundColor: t.palette[0]}]},", - " options: {responsive: true, maintainAspectRatio: false, animation: false,", - " plugins: {title: {display: true, text: 'Größe – smoke', color: t.ink}}},", - "});", - ]) - table = {"columns": ["Gruppe", "Wert"], "data": [["a", 3], ["b", 1], ["c", 2]]} - job = {"job_id": "smoke-chartjs", "language": "javascript", "library": "chartjs", "source": source, - "data_csv": "Gruppe,Wert\na,3\nb,1\nc,2\n", "data_json": json.dumps(table), - "themes": ["light"], "timeout_s": 60} - json.dump(job, open("chartjs_job.json", "w")) - EOF - chartjs_ok="" - for try in 1 2; do - if curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 200 -H "Authorization: Bearer $$TOKEN" \ - -H "Content-Type: application/json" --data @chartjs_job.json -o chartjs.json "$$URL/render" \ - && python3 -c "import json; r=json.load(open('chartjs.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s','stderr_tail')}; print('OK: Chart.js smoke render', o['wall_s'], 's wall,', o['cpu_s'], 's CPU,', o['max_rss_mb'], 'MiB,', o['attempts'], 'attempt(s)')"; then - chartjs_ok=1 - break - fi - echo "the Chart.js smoke render failed (try $$try of 2)" - if [ "$$try" = 1 ]; then sleep 30; fi - done - [ -n "$$chartjs_ok" ] || { echo "the Chart.js smoke render failed twice"; exit 1; } - # 2d. A plotly render through the two-step figure mode (PB-1, a gate since PB-2a served - # plotly and bokeh): the harness forks the plot under its guard and the exec filter, - # the plot reads data.csv and writes its figure as JSON through the pinned save (an - # axis range included, which runs plotly's `build_validator` under the guard), and - # the JavaScript row's harness draws it in Chromium under gVisor, which no local run - # can. The same two tries as the Chart.js render; a failure refuses the promotion. - python3 - <<'EOF' - import json - source = "\n".join([ - "import os", - "", - "import pandas as pd", - "import plotly.graph_objects as go", - "", - "THEME = os.getenv('ANYPLOT_THEME', 'light')", - "df = pd.read_csv('data.csv')", - "fig = go.Figure(go.Bar(x=df['Gruppe'], y=df['Wert'], marker_color='#009E73'))", - "fig.update_layout(title={'text': 'Größe – smoke'}, yaxis={'range': [0, 3.5]})", - "fig.update_layout(width=800, height=450).write_json(f'plot-{THEME}.json')", - ]) - job = {"job_id": "smoke-plotly", "language": "python", "library": "plotly", "source": source, - "data_csv": "Gruppe,Wert\na,3\nb,1\nc,2\n", "themes": ["light"], "timeout_s": 60} - json.dump(job, open("plotly_job.json", "w")) - EOF - plotly_ok="" - for try in 1 2; do - if curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 200 -H "Authorization: Bearer $$TOKEN" \ - -H "Content-Type: application/json" --data @plotly_job.json -o plotly.json "$$URL/render" \ - && python3 -c "import json; r=json.load(open('plotly.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s','stderr_tail')}; print('OK: plotly figure smoke render', o['wall_s'], 's wall,', o['cpu_s'], 's CPU,', o['max_rss_mb'], 'MiB,', o['attempts'], 'attempt(s)')"; then - plotly_ok=1 - break - fi - echo "the plotly figure smoke render failed (try $$try of 2)" - if [ "$$try" = 1 ]; then sleep 30; fi - done - [ -n "$$plotly_ok" ] || { echo "the plotly figure smoke render failed twice"; exit 1; } - # 3. A real data run (POST /run-data, run_data_code): the harness in - # data mode with its audit-hook guard armed returns a 2-row table. - python3 - <<'EOF' - import json - source = "import pandas as pd\ndf = pd.DataFrame({'x': [1, 2], 'y': [3, 4]})\n" - json.dump({"job_id": "smoke-data", "source": source, "timeout_s": 30}, open("data_job.json", "w")) - EOF - curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 120 -H "Authorization: Bearer $$TOKEN" \ - -H "Content-Type: application/json" --data @data_job.json -o data.json "$$URL/run-data" \ - || { echo "the smoke data run request failed"; exit 1; } - python3 -c "import json; r=json.load(open('data.json')); assert r['exit_code']==0 and (r['csv_text'] or '').splitlines()==['x,y','1,3','2,4'], {k: r[k] for k in ('exit_code','reason','attempts','wall_s')}; print('OK: smoke data run', r['wall_s'], 's,', r['attempts'], 'attempt(s)')" + # The render probes (matplotlib, Makie, Chart.js, plotly and a data run, each a gate) + # live in their own script: Cloud Build refuses a step argument over 10,000 + # characters, and this step passed it when the plotly probe joined (build dc1cf3ae, + # 2026-11-01). The token goes through the environment, not the command line. + SMOKE_TOKEN="$$TOKEN" bash agents/renderer/deploy_smoke.sh "$$URL" else echo "render smoke skipped (_SMOKE_RENDER=${_SMOKE_RENDER})" fi diff --git a/agents/renderer/deploy_smoke.sh b/agents/renderer/deploy_smoke.sh new file mode 100755 index 00000000000..32cff3c7526 --- /dev/null +++ b/agents/renderer/deploy_smoke.sh @@ -0,0 +1,146 @@ +#!/bin/bash +# The renderer deploy's render probes against the CANDIDATE revision (agents/renderer/cloudbuild.yaml, +# step "smoke", with _SMOKE_RENDER=true, after its IAM and /status checks). Each probe gates the +# promotion: a failure exits non-zero, and the revision serving now keeps serving. +# +# They live in this file because Cloud Build refuses a step argument longer than 10,000 characters +# ("invalid build: invalid .steps field: build step 7 arg 1 too long (max: 10000)"; build dc1cf3ae of +# 2026-11-01, when the plotly probe took the inline step to 11,347 characters and PB-2a's renderer never +# deployed). tests/unit/test_cloudbuild_configs.py holds every config's arguments under that limit. +# +# Usage, from the repository root (Cloud Build's /workspace): +# SMOKE_TOKEN= bash agents/renderer/deploy_smoke.sh +# The token comes through the environment, never as an argument. The probes write their request and +# response files into the working directory. +set -eu +URL="$1" +TOKEN="$SMOKE_TOKEN" + +# 2a. A matplotlib render: the launcher, the in-memory volume, the harness and the caller check, end to end. +python3 - <<'EOF' +import json +source = "\n".join([ + "import matplotlib.pyplot as plt", + "fig, ax = plt.subplots(figsize=(16, 9), dpi=200)", + "ax.plot([1, 2, 3, 4], [3, 1, 4, 2], linewidth=6)", + "fig.savefig('plot-light.png')", +]) +job = {"job_id": "smoke", "language": "python", "library": "matplotlib", "source": source, + "data_csv": "x\n1\n", "themes": ["light"], "timeout_s": 60} +json.dump(job, open("job.json", "w")) +EOF +curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 200 -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" --data @job.json -o render.json "$URL/render" \ + || { echo "the smoke render request failed"; exit 1; } +python3 -c "import json; r=json.load(open('render.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s')}; print('OK: smoke render', o['wall_s'], 's,', o['attempts'], 'attempt(s)')" + +# 2b. A Makie render through the Julia row (step 5 of agentic/specs/261024-agent-all-libraries.md): the +# depot's package images load under gVisor with --compiled-modules=strict and the preloaded exec filter, +# or the render fails within seconds and nothing is promoted. The first Julia render of a fresh +# instance, so its wall time is the cold one. +python3 - <<'EOF' +import json +source = "\n".join([ + "using CairoMakie", + "THEME = get(ENV, \"ANYPLOT_THEME\", \"light\")", + "fig = Figure(size = (1600, 900))", + "ax = Axis(fig[1, 1], title = \"Größe – smoke\")", + "lines!(ax, [1, 2, 3, 4], [3, 1, 4, 2], linewidth = 6)", + "save(\"plot-$(THEME).png\", fig; px_per_unit = 2)", +]) +job = {"job_id": "smoke-makie", "language": "julia", "library": "makie", "source": source, + "data_csv": "x\n1\n", "themes": ["light"], "timeout_s": 90} +json.dump(job, open("makie_job.json", "w")) +EOF +curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 240 -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" --data @makie_job.json -o makie.json "$URL/render" \ + || { echo "the Makie smoke render request failed"; exit 1; } +python3 -c "import json; r=json.load(open('makie.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s','stderr_tail')}; print('OK: Makie smoke render', o['wall_s'], 's wall,', o['cpu_s'], 's CPU,', o['max_rss_mb'], 'MiB,', o['attempts'], 'attempt(s)')" + +# 2c. A Chart.js render through the JavaScript row (JS-1 of step 3): Node by its path starts Chromium's +# headless shell under gVisor, which no local run can, and the page reads the job's typed table +# (`data_json`, `window.ANYPLOT_DATA`), as every agent job does. A gate like the Makie render since JS-4a +# served JavaScript. curl's retries cover the transport only, and a render that fails answers 200, so +# the render gets a second try 30 s later; each failure prints the exit code, the reason, the attempts +# and the stderr tail (JS renders failed on Cloud Run for about 40 minutes after a fresh toolchain build +# on 2026-10-30, cause unproven). +python3 - <<'EOF' +import json +source = "\n".join([ + "const t = window.ANYPLOT_TOKENS;", + "const data = window.ANYPLOT_DATA;", + "const canvas = document.createElement('canvas');", + "document.getElementById('container').appendChild(canvas);", + "new Chart(canvas, {", + " type: 'bar',", + " data: {labels: data.map((d) => d.Gruppe), datasets: [{data: data.map((d) => d.Wert), backgroundColor: t.palette[0]}]},", + " options: {responsive: true, maintainAspectRatio: false, animation: false,", + " plugins: {title: {display: true, text: 'Größe – smoke', color: t.ink}}},", + "});", +]) +table = {"columns": ["Gruppe", "Wert"], "data": [["a", 3], ["b", 1], ["c", 2]]} +job = {"job_id": "smoke-chartjs", "language": "javascript", "library": "chartjs", "source": source, + "data_csv": "Gruppe,Wert\na,3\nb,1\nc,2\n", "data_json": json.dumps(table), + "themes": ["light"], "timeout_s": 60} +json.dump(job, open("chartjs_job.json", "w")) +EOF +chartjs_ok="" +for try in 1 2; do + if curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 200 -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" --data @chartjs_job.json -o chartjs.json "$URL/render" \ + && python3 -c "import json; r=json.load(open('chartjs.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s','stderr_tail')}; print('OK: Chart.js smoke render', o['wall_s'], 's wall,', o['cpu_s'], 's CPU,', o['max_rss_mb'], 'MiB,', o['attempts'], 'attempt(s)')"; then + chartjs_ok=1 + break + fi + echo "the Chart.js smoke render failed (try $try of 2)" + if [ "$try" = 1 ]; then sleep 30; fi +done +[ -n "$chartjs_ok" ] || { echo "the Chart.js smoke render failed twice"; exit 1; } + +# 2d. A plotly render through the two-step figure mode (PB-1, a gate since PB-2a served plotly and bokeh): +# the harness forks the plot under its guard and the exec filter, the plot reads data.csv and writes its +# figure as JSON through the pinned save (an axis range included, which runs plotly's `build_validator` +# under the guard), and the JavaScript row's harness draws it in Chromium under gVisor, which no local +# run can. The same two tries as the Chart.js render; a failure refuses the promotion. +python3 - <<'EOF' +import json +source = "\n".join([ + "import os", + "", + "import pandas as pd", + "import plotly.graph_objects as go", + "", + "THEME = os.getenv('ANYPLOT_THEME', 'light')", + "df = pd.read_csv('data.csv')", + "fig = go.Figure(go.Bar(x=df['Gruppe'], y=df['Wert'], marker_color='#009E73'))", + "fig.update_layout(title={'text': 'Größe – smoke'}, yaxis={'range': [0, 3.5]})", + "fig.update_layout(width=800, height=450).write_json(f'plot-{THEME}.json')", +]) +job = {"job_id": "smoke-plotly", "language": "python", "library": "plotly", "source": source, + "data_csv": "Gruppe,Wert\na,3\nb,1\nc,2\n", "themes": ["light"], "timeout_s": 60} +json.dump(job, open("plotly_job.json", "w")) +EOF +plotly_ok="" +for try in 1 2; do + if curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 200 -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" --data @plotly_job.json -o plotly.json "$URL/render" \ + && python3 -c "import json; r=json.load(open('plotly.json')); o=r['outputs']['light']; assert o['exit_code']==0 and o['png_base64'], {k: o[k] for k in ('exit_code','reason','attempts','wall_s','stderr_tail')}; print('OK: plotly figure smoke render', o['wall_s'], 's wall,', o['cpu_s'], 's CPU,', o['max_rss_mb'], 'MiB,', o['attempts'], 'attempt(s)')"; then + plotly_ok=1 + break + fi + echo "the plotly figure smoke render failed (try $try of 2)" + if [ "$try" = 1 ]; then sleep 30; fi +done +[ -n "$plotly_ok" ] || { echo "the plotly figure smoke render failed twice"; exit 1; } + +# 3. A real data run (POST /run-data, run_data_code): the harness in data mode with its audit-hook guard +# armed returns a 2-row table. +python3 - <<'EOF' +import json +source = "import pandas as pd\ndf = pd.DataFrame({'x': [1, 2], 'y': [3, 4]})\n" +json.dump({"job_id": "smoke-data", "source": source, "timeout_s": 30}, open("data_job.json", "w")) +EOF +curl -fsS --retry 3 --retry-delay 10 --retry-all-errors --max-time 120 -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" --data @data_job.json -o data.json "$URL/run-data" \ + || { echo "the smoke data run request failed"; exit 1; } +python3 -c "import json; r=json.load(open('data.json')); assert r['exit_code']==0 and (r['csv_text'] or '').splitlines()==['x,y','1,3','2,4'], {k: r[k] for k in ('exit_code','reason','attempts','wall_s')}; print('OK: smoke data run', r['wall_s'], 's,', r['attempts'], 'attempt(s)')" diff --git a/changelog.d/renderer-smoke-script.md b/changelog.d/renderer-smoke-script.md new file mode 100644 index 00000000000..5e6a984c096 --- /dev/null +++ b/changelog.d/renderer-smoke-script.md @@ -0,0 +1,10 @@ +### Fixed + +- **The renderer deploys again, with plotly and bokeh.** Cloud Build refused the renderer's + deploy for PB-2a before its first step ran, because the inline smoke step had grown to + 11,347 characters and Cloud Build accepts at most 10,000 per step argument; the agents + service deployed and the renderer kept its previous revision. The render probes now live + in `agents/renderer/deploy_smoke.sh`, which the smoke step calls with the token in the + environment, a new unit test keeps every Cloud Build config's arguments under the limit, + and `ci-tests.yml` runs the unit tests when a Cloud Build config or the smoke script + changes. (#12497) diff --git a/docs/concepts/agent-network.md b/docs/concepts/agent-network.md index b92db868a5e..99344253748 100644 --- a/docs/concepts/agent-network.md +++ b/docs/concepts/agent-network.md @@ -494,7 +494,7 @@ Built on 2026-10-10: the harness, the two fixture generators and the 146 fixture ## Serving and infrastructure - **`anyplot-agents`**, a new Cloud Run service built from `agents/Dockerfile` and deployed by `agents/cloudbuild.yaml` (both built on 2026-10-10; the service has run at `https://anyplot-agents-r3tvmejsmq-ez.a.run.app` since 2026-10-11, deployed by the `deploy-agents` trigger on every push to `main` that touches it) with the renderer's candidate-then-smoke-then-promote steps and `--update-*` flags only. Like the renderer's, its deploy step creates the service on the first build without `--no-traffic`, because gcloud rejects `--no-traffic` on a new service, and only when `services describe` reports the service as not found; the API side stayed dark through `AGENT_ENABLED=false` until the switch-on of 2026-10-11. Flags: `gcloud run deploy anyplot-agents --region=europe-west4 --execution-environment=gen2 --no-allow-unauthenticated --service-account=anyplot-agents@anyplot.iam.gserviceaccount.com --cpu=1 --memory=2Gi --min-instances=${_MIN_INSTANCES} --max-instances=1 --concurrency=20 --timeout=1200 --update-env-vars=^|^... --no-traffic --tag=candidate`, where `_MIN_INSTANCES` is 0 unless a build sets 1. No secrets and no Cloud SQL in phase 1. The smoke step checks that Cloud Run IAM refuses an anonymous `GET /v1/status` with exactly `403` (a `401` would come from the container's own caller check, past IAM) and, with `_SMOKE_AUTH=true`, calls `/v1/status` with an ID token impersonated for `anyplot-agents@` (the renderer's token recipe) and checks that it names the provider, model, location and libraries the build configured; the bootstrap build runs with `_SMOKE_AUTH=false`, because the service has no URL and no invoker binding before it exists. The image installs `--extra agents` without plotting extras, copies the source tree with the prompts, the fonts and the catalogue prompt sources at their repository paths, runs as uid 1000, and fails its build when a prompt source or a font is missing: the build imports `agents.main` and reads the two judge rubrics, which the service otherwise reads on first use. The root `.dockerignore` keeps `agents/evals` and ADK's local `.adk/` store, both of which can hold users' data, out of the image. `.github/workflows/ci-image.yml` builds it before merge and checks `/v1/status`, the production default's `401` for an anonymous caller, the files in the image and the uid. The concurrency and the timeout follow the run queue, not the memory: the queue, not Cloud Run, limits the runs in memory, but every queued run holds an open stream, so the instance must accept the queue's 10 waiting streams, the run in flight and the other routes (`--concurrency=20`), and one turn can last the 600 s maximum wait plus the 540 s deadline (`--timeout=1200` since 2026-10-13, 900 before). The FastAPI app in `agents/main.py` sets `docs_url=None`. The service runs no code of its own users: it has no `--sandbox-launcher`, and every render goes to `anyplot-renderer` through the `remote` backend. Its 2 vCPU and 4 GiB were sized when it still hosted the sandboxes; since 2026-10-29 it runs on 1 vCPU and 2 GiB (owner decision): over the 14 days before, its hourly p99 peaked at 0.46 of a core and 370 MiB, and the worst case of about 1.1 GiB (the dataset and render stores' 256 MiB caps, the process, a 1 MB paste's parse and the PNG gates) keeps headroom, because an out-of-memory kill would lose every session in memory. The renderer keeps 2 vCPU and 4 GiB: its renders peaked at 1.2 cores (the sandbox's gVisor and the host share the CPU with the render), a Makie render needs up to 1.5 GiB, and a single render is no faster with more CPUs (a Makie heatmap took 8.9 s locally at 1, 2 and 4 CPUs alike: Julia compiles on one thread). Both services scale to zero and bill only while they serve a request. -- **`anyplot-renderer`**, a phase-1 Cloud Run service that runs the code (`agents/renderer/`), deployed by `agents/renderer/cloudbuild.yaml` with the same candidate, smoke and promote steps. Since 2026-10-29 its image builds on the toolchain image `anyplot-toolchain` (`agents/renderer/toolchain.Dockerfile`: `python:3.14-slim` pinned by digest, R 4.6.1 with the pipeline's package set, and the exec filter's preload library for the exec-mode rows; step 2a of `agentic/specs/261024-agent-all-libraries.md`). The deploy hashes the toolchain's inputs (`agents/renderer/toolchain.py`), builds and pushes `anyplot-toolchain:` to the private Artifact Registry repository `anyplot` only when that tag is missing, and builds the renderer from it by digest; the CI job pulls the same tag through Workload Identity Federation (`roles/artifactregistry.reader` on the repository) or builds it. Its deploy step creates the service on the first build, because gcloud refuses `--no-traffic` for a service that does not exist yet; it does so only when `services describe` reports the service as not found, and stops on any other describe error, so a permission or API error never sends all traffic to an unsmoked revision. Flags: `gcloud beta run deploy anyplot-renderer --region=europe-west4 --execution-environment=gen2 --sandbox-launcher --no-allow-unauthenticated --service-account=anyplot-renderer@anyplot.iam.gserviceaccount.com --cpu=2 --memory=4Gi --min-instances=${_MIN_INSTANCES} --max-instances=1 --concurrency=4 --timeout=300 --add-volume=name=runs,type=in-memory,size-limit=512Mi --add-volume-mount=volume=runs,mount-path=/tmp/runs --no-traffic --tag=candidate --update-env-vars=^|^ENVIRONMENT=production|RENDERER_ALLOWED_CALLERS=...|RENDERER_AUDIENCES=...`, where `_MIN_INSTANCES` is 0 unless a build sets 1 to keep an instance warm. One instance with one render slot, because renders are serial; `--concurrency=4` so a second caller gets the slot's `503 busy` after 30 s instead of a Cloud Run 429, and a cancel reaches the instance while a render holds the slot; the run directories on the in-memory volume (see [Render](#render)). No public domain, no secrets, no Cloud SQL and no role: the service account exists only to be an identity. The process runs as root, because `sandbox do` creates the gVisor sandboxes; the model-written code runs inside them. The smoke step checks that IAM refuses an anonymous caller (403) and, with `_SMOKE_RENDER=true`, checks that `/status` reports the launcher and the volume and renders a matplotlib plot through the candidate as the service's own account, which proves the launcher, the volume and the harness together. The build impersonates `anyplot-renderer@` for that token (`gcloud auth print-identity-token --impersonate-service-account`), because a build cannot mint an ID token for its own identity: gcloud refuses `--include-email` for it and Cloud Build's metadata server has no `identity` endpoint (builds `da6f53f5` and `a9d3ac7f` of 2026-10-10). The token is minted for the service URL while the requests go to the `candidate` tag URL, because Cloud Run accepts no other audience. `.github/workflows/ci-image.yml` builds the same image before merge and renders a seaborn plot through its harness. +- **`anyplot-renderer`**, a phase-1 Cloud Run service that runs the code (`agents/renderer/`), deployed by `agents/renderer/cloudbuild.yaml` with the same candidate, smoke and promote steps. Since 2026-10-29 its image builds on the toolchain image `anyplot-toolchain` (`agents/renderer/toolchain.Dockerfile`: `python:3.14-slim` pinned by digest, R 4.6.1 with the pipeline's package set, and the exec filter's preload library for the exec-mode rows; step 2a of `agentic/specs/261024-agent-all-libraries.md`). The deploy hashes the toolchain's inputs (`agents/renderer/toolchain.py`), builds and pushes `anyplot-toolchain:` to the private Artifact Registry repository `anyplot` only when that tag is missing, and builds the renderer from it by digest; the CI job pulls the same tag through Workload Identity Federation (`roles/artifactregistry.reader` on the repository) or builds it. Its deploy step creates the service on the first build, because gcloud refuses `--no-traffic` for a service that does not exist yet; it does so only when `services describe` reports the service as not found, and stops on any other describe error, so a permission or API error never sends all traffic to an unsmoked revision. Flags: `gcloud beta run deploy anyplot-renderer --region=europe-west4 --execution-environment=gen2 --sandbox-launcher --no-allow-unauthenticated --service-account=anyplot-renderer@anyplot.iam.gserviceaccount.com --cpu=2 --memory=4Gi --min-instances=${_MIN_INSTANCES} --max-instances=1 --concurrency=4 --timeout=300 --add-volume=name=runs,type=in-memory,size-limit=512Mi --add-volume-mount=volume=runs,mount-path=/tmp/runs --no-traffic --tag=candidate --update-env-vars=^|^ENVIRONMENT=production|RENDERER_ALLOWED_CALLERS=...|RENDERER_AUDIENCES=...`, where `_MIN_INSTANCES` is 0 unless a build sets 1 to keep an instance warm. One instance with one render slot, because renders are serial; `--concurrency=4` so a second caller gets the slot's `503 busy` after 30 s instead of a Cloud Run 429, and a cancel reaches the instance while a render holds the slot; the run directories on the in-memory volume (see [Render](#render)). No public domain, no secrets, no Cloud SQL and no role: the service account exists only to be an identity. The process runs as root, because `sandbox do` creates the gVisor sandboxes; the model-written code runs inside them. The smoke step checks that IAM refuses an anonymous caller (403) and, with `_SMOKE_RENDER=true`, checks that `/status` reports the launcher and the volume and renders a matplotlib plot through the candidate as the service's own account, which proves the launcher, the volume and the harness together; a Makie, a Chart.js and a plotly render and a data run follow, and each one gates the promotion too. These render probes live in `agents/renderer/deploy_smoke.sh`, which the step calls with the token in the environment, because Cloud Build refuses the whole build when one step argument is longer than 10,000 characters: the inline step reached 11,347 with the plotly probe, and PB-2a's renderer did not deploy (build `dc1cf3ae` of 2026-11-01). `tests/unit/test_cloudbuild_configs.py` keeps every Cloud Build config of the repository under that limit. The build impersonates `anyplot-renderer@` for that token (`gcloud auth print-identity-token --impersonate-service-account`), because a build cannot mint an ID token for its own identity: gcloud refuses `--include-email` for it and Cloud Build's metadata server has no `identity` endpoint (builds `da6f53f5` and `a9d3ac7f` of 2026-10-10). The token is minted for the service URL while the requests go to the `candidate` tag URL, because Cloud Run accepts no other audience. `.github/workflows/ci-image.yml` builds the same image before merge and renders a seaborn plot through its harness. | Service | Runs | Called by | Identity and roles | |---|---|---|---| diff --git a/tests/unit/agents/test_image.py b/tests/unit/agents/test_image.py index 37e4d574e4e..45f5c8747f7 100644 --- a/tests/unit/agents/test_image.py +++ b/tests/unit/agents/test_image.py @@ -46,6 +46,7 @@ DOCKERIGNORE = REPO_ROOT / ".dockerignore" CLOUDBUILD = REPO_ROOT / "agents" / "cloudbuild.yaml" RENDERER_CLOUDBUILD = REPO_ROOT / "agents" / "renderer" / "cloudbuild.yaml" +DEPLOY_SMOKE = REPO_ROOT / "agents" / "renderer" / "deploy_smoke.sh" CLOUD_BUILD_BUILTINS = frozenset({"PROJECT_ID", "PROJECT_NUMBER", "BUILD_ID", "LOCATION"}) """The built-in substitutions these configs use; anything else unescaped must be a `_NAME`.""" @@ -767,15 +768,27 @@ def test_the_deploy_runs_on_eight_vcpus_with_an_hour(self) -> None: assert config["options"] == {"logging": "CLOUD_LOGGING_ONLY", "machineType": "E2_HIGHCPU_8"} assert int(config["timeout"].removesuffix("s")) >= 3600 + def test_the_smoke_step_hands_its_render_probes_to_the_script(self) -> None: + """Cloud Build refuses a step argument over 10,000 characters (build dc1cf3ae, 2026-11-01), so the render + probes live in `deploy_smoke.sh`; the step keeps the candidate, IAM and /status checks and passes the token + through the environment, never on a command line.""" + smoke = _step(_config(RENDERER_CLOUDBUILD), "smoke") + call = 'SMOKE_TOKEN="$$TOKEN" bash agents/renderer/deploy_smoke.sh "$$URL"' + assert smoke.count(call) == 1 + rendered = smoke[smoke.index('if [ "${_SMOKE_RENDER}" = "true" ]') : smoke.index("render smoke skipped")] + assert rendered.index("-o status.json") < rendered.index(call) + assert smoke.index(call) < smoke.index("REV_AFTER") + assert "<<'EOF'" not in smoke + assert "$$" not in DEPLOY_SMOKE.read_text(encoding="utf-8") # a plain script: no Cloud Build escapes + def test_the_smoke_renders_makie_through_the_julia_row(self, workspace: Path) -> None: """The first deploy proves the depot's package images load under gVisor with `--compiled-modules=strict`.""" - smoke = _step(_config(RENDERER_CLOUDBUILD), "smoke") + smoke = DEPLOY_SMOKE.read_text(encoding="utf-8") assert '"language": "julia", "library": "makie"' in smoke assert "--data @makie_job.json -o makie.json" in smoke assert "OK: Makie smoke render" in smoke and "o['wall_s']" in smoke - # The job the heredoc writes is a valid request, with Julia's `$(THEME)` once Cloud Build unescapes `$$`. - values = {**_config(RENDERER_CLOUDBUILD)["substitutions"], "PROJECT_ID": "anyplot", "BUILD_ID": "build-1"} - heredocs = re.findall(r"<<'EOF'\n(.*?)\nEOF\n", _substituted(smoke, values), re.DOTALL) + # The job the heredoc writes is a valid request, with Julia's `$(THEME)` left to Julia. + heredocs = re.findall(r"<<'EOF'\n(.*?)\nEOF\n", smoke, re.DOTALL) makie = next(heredoc for heredoc in heredocs if "smoke-makie" in heredoc) subprocess.run([sys.executable, "-c", makie], cwd=workspace, check=True, timeout=30) job = RenderRequest.model_validate_json((workspace / "makie_job.json").read_text(encoding="utf-8")) @@ -786,9 +799,8 @@ def test_the_smoke_renders_makie_through_the_julia_row(self, workspace: Path) -> def test_the_smoke_renders_chartjs_from_its_typed_table_as_a_gate(self, workspace: Path) -> None: """JS-1 measured a cold Chromium under gVisor with a warning only; JS-4a serves JavaScript, so a failed render refuses the promotion like the Makie render, and the page reads the job's typed table.""" - smoke = _step(_config(RENDERER_CLOUDBUILD), "smoke") - values = {**_config(RENDERER_CLOUDBUILD)["substitutions"], "PROJECT_ID": "anyplot", "BUILD_ID": "build-1"} - heredocs = re.findall(r"<<'EOF'\n(.*?)\nEOF\n", _substituted(smoke, values), re.DOTALL) + smoke = DEPLOY_SMOKE.read_text(encoding="utf-8") + heredocs = re.findall(r"<<'EOF'\n(.*?)\nEOF\n", smoke, re.DOTALL) chartjs = next(heredoc for heredoc in heredocs if "smoke-chartjs" in heredoc) subprocess.run([sys.executable, "-c", chartjs], cwd=workspace, check=True, timeout=30) job = RenderRequest.model_validate_json((workspace / "chartjs_job.json").read_text(encoding="utf-8")) @@ -805,3 +817,95 @@ def test_the_smoke_renders_chartjs_from_its_typed_table_as_a_gate(self, workspac assert "for try in 1 2; do" in retried and "sleep 30" in retried and "--data @chartjs_job.json" in retried # Between the Makie render and the data run, which all gate the promotion. assert smoke.index("smoke-makie") < smoke.index("smoke-chartjs") < smoke.index("smoke-data") + + def test_the_smoke_renders_plotly_through_the_figure_mode_as_a_gate(self, workspace: Path) -> None: + """PB-2a serves plotly and bokeh: a plotly figure written as JSON is drawn by the browser harness, and a + failed render refuses the promotion after the same second try as Chart.js.""" + smoke = DEPLOY_SMOKE.read_text(encoding="utf-8") + heredocs = re.findall(r"<<'EOF'\n(.*?)\nEOF\n", smoke, re.DOTALL) + plotly = next(heredoc for heredoc in heredocs if "smoke-plotly" in heredoc) + subprocess.run([sys.executable, "-c", plotly], cwd=workspace, check=True, timeout=30) + job = RenderRequest.model_validate_json((workspace / "plotly_job.json").read_text(encoding="utf-8")) + assert (job.language, job.library, job.themes) == ("python", "plotly", ["light"]) + assert "write_json(f'plot-{THEME}.json')" in job.source and "pd.read_csv('data.csv')" in job.source + retried = smoke[smoke.index("plotly_ok") : smoke.index("# 3. A real data run")] + assert "for try in 1 2; do" in retried and "sleep 30" in retried and "--data @plotly_job.json" in retried + assert 'the plotly figure smoke render failed twice"; exit 1' in retried + assert smoke.index("smoke-chartjs") < smoke.index("smoke-plotly") < smoke.index("smoke-data") + + def test_the_smoke_script_runs_every_probe_with_the_token(self, tmp_path: Path) -> None: + """The script end to end against a fake renderer: every probe sends the token, and all five pass.""" + code, output, calls = _run_deploy_smoke(tmp_path) + assert code == 0, output + for line in ("OK: smoke render", "OK: Makie smoke render", "OK: Chart.js smoke render"): + assert line in output, output + assert "OK: plotly figure smoke render" in output and "OK: smoke data run" in output, output + assert [call.split()[-1] for call in calls] == [ + "job.json", + "makie_job.json", + "chartjs_job.json", + "plotly_job.json", + "data_job.json", + ] + assert all(call.startswith("Authorization: Bearer tok-123 https://candidate.example/") for call in calls) + + def test_a_plotly_render_that_fails_twice_refuses_the_promotion(self, tmp_path: Path) -> None: + code, output, calls = _run_deploy_smoke(tmp_path, fail="smoke-plotly") + assert code != 0 and "the plotly figure smoke render failed twice" in output, output + assert [call.split()[-1] for call in calls].count("plotly_job.json") == 2 + assert not any(call.endswith("data_job.json") for call in calls) + + +FAKE_RENDERER_CURL = """#!/bin/sh +# curl as the smoke script calls it: `-o` names the response file, `--data @file` the job, the URL is the one +# argument that is no flag and no flag's value; an unknown flag fails the probe. +out=""; data=""; auth=""; url="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + --data) data="${2#@}"; shift 2 ;; + -H) case "$2" in Authorization:*) auth="$2" ;; esac; shift 2 ;; + --retry|--retry-delay|--max-time) shift 2 ;; + -fsS|--retry-all-errors) shift ;; + -*) echo "unexpected curl flag $1" >&2; exit 2 ;; + *) [ -z "$url" ] || { echo "two URLs: $url $1" >&2; exit 2; }; url="$1"; shift ;; + esac +done +echo "$auth $url $data" >> "$FAKE_CURL_LOG" +case "$url" in + */run-data) + printf '%s' '{"exit_code": 0, "csv_text": "x,y\\n1,3\\n2,4\\n", "reason": null, "attempts": 1, "wall_s": 0.1}' > "$out" ;; + */render) + code=0; png=iVBOR + if [ -n "$FAKE_FAIL" ] && grep -q "\\"$FAKE_FAIL\\"" "$data"; then code=1; png=""; fi + printf '{"outputs": {"light": {"exit_code": %s, "png_base64": "%s", "reason": null, "attempts": 1, "wall_s": 0.1, "cpu_s": 0.1, "max_rss_mb": 1, "stderr_tail": ""}}}' "$code" "$png" > "$out" ;; + *) echo "unexpected URL $url" >&2; exit 2 ;; +esac +""" + + +def _run_deploy_smoke(workdir: Path, fail: str = "") -> tuple[int, str, list[str]]: + """Run `deploy_smoke.sh` in `workdir` against a fake curl (and a `sleep` that returns at once).""" + bin_dir = workdir / "bin" + bin_dir.mkdir() + for name, body in (("curl", FAKE_RENDERER_CURL), ("sleep", "#!/bin/sh\nexit 0\n")): + (bin_dir / name).write_text(body, encoding="utf-8") + (bin_dir / name).chmod(0o755) + log = workdir / "curl.log" + env = { + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "SMOKE_TOKEN": "tok-123", + "FAKE_CURL_LOG": str(log), + "FAKE_FAIL": fail, + } + result = subprocess.run( + ["bash", str(DEPLOY_SMOKE), "https://candidate.example"], + cwd=workdir, + env=env, + capture_output=True, + text=True, + timeout=60, + check=False, + ) + calls = log.read_text(encoding="utf-8").splitlines() if log.exists() else [] + return result.returncode, result.stdout + result.stderr, calls diff --git a/tests/unit/test_cloudbuild_configs.py b/tests/unit/test_cloudbuild_configs.py new file mode 100644 index 00000000000..e1af6209f86 --- /dev/null +++ b/tests/unit/test_cloudbuild_configs.py @@ -0,0 +1,51 @@ +"""Every Cloud Build config of the repository stays inside Cloud Build's own limits. + +Cloud Build refuses the whole build, before its first step runs, when one step argument is longer than 10,000 +characters ("invalid build: invalid .steps field: build step 7 arg 1 too long (max: 10000)"). PB-2a's plotly +probe took the renderer's inline smoke step to 11,347 characters; every PR check passed, the agents service +deployed, and the renderer did not (build dc1cf3ae, 2026-11-01). The length is measured on the config as +written, before Cloud Build substitutes `$NAME` and `${NAME}`. +""" + +import subprocess +from pathlib import Path +from typing import Any + +import pytest +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[2] +ARG_LIMIT = 10_000 +# Every tracked config at any depth; git, not a recursive glob, because a checkout's untracked +# `.claude/worktrees/` and `node_modules/` hold copies that are not this tree's configs. +CONFIGS = sorted( + subprocess.run( + ["git", "ls-files", "--", "*cloudbuild*.yaml", "*cloudbuild*.yml"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout.split() +) + + +def test_every_deploy_config_is_checked() -> None: + assert { + "agents/cloudbuild.yaml", + "agents/renderer/cloudbuild.yaml", + "api/cloudbuild.yaml", + "app/cloudbuild.yaml", + } <= set(CONFIGS) + + +@pytest.mark.parametrize("config", CONFIGS) +def test_every_step_argument_fits_cloud_build(config: str) -> None: + loaded: dict[str, Any] = yaml.safe_load((REPO_ROOT / config).read_text(encoding="utf-8")) + too_long = [ + f"step {index} ({step.get('id', step.get('name'))}) arg {position}: {len(arg)} characters" + for index, step in enumerate(loaded["steps"]) + for position, arg in enumerate(step.get("args", [])) + if len(arg) > ARG_LIMIT + ] + assert not too_long, f"Cloud Build refuses an argument over {ARG_LIMIT:,} characters: {too_long}"