-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathdocker-compose.yml
More file actions
546 lines (540 loc) · 29.5 KB
/
Copy pathdocker-compose.yml
File metadata and controls
546 lines (540 loc) · 29.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
# Bind-mount sources go through ${HARNESS_ROOT:-.} rather than a bare
# `./`, because compose resolves a relative source against the project
# directory as seen by whichever process builds the command -- and that
# is not always the daemon's view of the filesystem. Confirmed live:
# driven from cicd_runner's coordinator, `./auth-data/auth.json` became
# `/dynamic-root/opencode/opencode-model-eval/auth-data/auth.json`, a
# path that exists only inside that container. The daemon then created
# an empty directory tree at the host root and the server crash-looped
# on "credentials not found" -- the same phantom-directory failure
# terraform/main.tf already documents for a missing bind source.
#
# The default is `.`, so a shell invocation behaves exactly as before.
# Only something driving compose from elsewhere needs to set it, in
# .env, to this directory's absolute host path.
#
# Derive that value with `make print-harness-root`, which reads it from
# where the Makefile is. Never `realpath` or `readlink -f`, and not a
# bare `pwd` either -- that is the caller's working directory, which is
# only the right answer by coincidence. The same file is resolved in
# three other places -- the extraction scripts
# (`cd "$(dirname "${BASH_SOURCE[0]}")" && pwd` plus a literal `/../`),
# Terraform's `abspath()`, and here -- and none of those resolve
# symlinks. A resolved path that disagrees with them is a different
# mount-source string for the same inode, which shows up as Terraform
# drift and a container recreated on every plan. No component of the
# path is a symlink today; this keeps that from mattering if one ever is.
x-harness-defaults: &harness-defaults
# One-shot roles. A service carrying a profile is not started by a bare
# `docker compose up -d`, which is exactly the command an orchestrator
# constructs: cicd_runner's container_control takes a directory and an
# action and appends nothing from the caller, deliberately, so that it
# stays reusable across projects that it knows nothing about. Scoping
# what "up" means is therefore this file's job, not the runner's.
# Naming a service explicitly (`up -d discover`, `run --rm eval`, as
# every Makefile target here does) enables its profile on the spot, so
# nothing at a shell changes.
profiles: ["oneshot"]
# Explicit image name, matching what terraform/main.tf already declares
# for the same stage. Without it Compose derives one from the project
# directory and the service name, which is why discover/eval/
# git-workspace each carried their own copy of an identical image, and
# why the derived name changes shape between the v1 and v2 CLIs (an
# underscore against a hyphen -- see existing_server_backend()'s grep).
image: opencode-model-eval-harness:latest
# nproc: RLIMIT_NPROC is enforced per real uid system-wide, not per
# container, and this daemon's own default is 128:256 -- measured, not
# assumed. Every exec after entrypoint.sh's uid switch fails with
# EAGAIN at that ceiling, so this is a prerequisite of the drop, not a
# tuning knob.
ulimits:
nproc: 8192
build:
context: .
dockerfile: Dockerfile
# Explicit target. Without it this anchor built whatever the LAST
# stage in the Dockerfile happens to be, and that stopped being
# `harness` when the `jupyter` stage was appended after it -- so
# discover and eval were silently building and running the jupyter
# image, jupyterlab and all.
target: harness
args:
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode}
# Pin this to a resolved digest (OPENCODE_REF=sha256:<digest>) once
# you've verified it against your own `docker inspect` output — see
# README for why "latest" is only a placeholder default.
OPENCODE_REF: ${OPENCODE_REF:-latest}
# Bakes a passwd entry for this host's uid:gid into the image, so
# entrypoint.sh's drop finds it already present.
WORKER_UID: ${HOST_UID:-1000}
WORKER_GID: ${HOST_GID:-1000}
# No network egress restriction is applied here at the compose level —
# the provider API host still needs outbound access. Add a network
# policy at the host/firewall layer if you want per-model egress scoping.
services:
# PROVIDER-REQUEST RECORDER. Runs always, proxies nothing unless the
# server is told to use it (OPENCODE_CAPTURE_PROXY in .env) -- an idle
# listener costs nothing, while a service behind a profile would not
# start under `container_control up`, which appends no arguments by
# design.
#
# WHAT IT IS FOR: opencode assembles the system array and tool
# definitions per request and hands them straight to the provider
# without persisting them, so the outbound request is the only place
# the resolved instruction set exists as bytes. Recording it is the
# difference between knowing what a model answered and knowing what it
# was asked.
#
# SCOPE, stated rather than discovered later: a plain-HTTP provider
# (local Ollama) is fully visible. An HTTPS provider arrives as
# CONNECT and its body stays encrypted end to end -- the record says
# so per request rather than a cloud run looking like it made none.
capture:
image: opencode-model-eval-harness:latest
container_name: opencode-model-eval-capture
ulimits:
nproc: 8192
build:
context: .
dockerfile: Dockerfile
target: harness
args:
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode}
OPENCODE_REF: ${OPENCODE_REF:-latest}
WORKER_UID: ${HOST_UID:-1000}
WORKER_GID: ${HOST_GID:-1000}
environment:
WORKER_UID: "${HOST_UID:-1000}"
WORKER_GID: "${HOST_GID:-1000}"
# A PROXY MUST REACH EVERYWHERE ITS CLIENT COULD. Without this the
# server resolves host.docker.internal and this container does not,
# so every captured request was recorded and then failed to forward
# with "Name does not resolve" -- the eval saw a dead provider while
# the capture file filled up correctly.
extra_hosts:
- "host.docker.internal:host-gateway"
# The script is mounted rather than baked, so a change to it takes
# effect on restart instead of on rebuild -- this is diagnostic
# tooling, iterated on far more often than the image.
# entrypoint.sh dispatches on a MODE name ('serve', 'eval-client',
# 'jupyter') and refuses anything else, so it cannot exec a bare
# command -- confirmed the hard way: FATAL: unknown mode 'python3'.
# Bypassing it costs the uid drop, so `user:` supplies the same
# thing directly, exactly as the discover service does. Safe here
# because the image bakes a passwd entry for this uid at build time
# (WORKER_UID build arg); without that a bare uid has no passwd
# record and every exec fails.
entrypoint: []
user: "${HOST_UID:-1000}:${HOST_GID:-1000}"
command:
- "python3"
- "/opt/capture/scripts/tools/capture_proxy.py"
- "--port"
- "8888"
- "--output"
- "/results/capture/provider-requests.jsonl"
# --verbose is deliberately NOT set by default. It prints a line per
# captured request to stderr, and a single server start produces
# ~190 of them from the provider-package fetch -- enough to bury
# the server's own output in `docker compose logs`. The JSONL file
# is the record; stderr is only for watching it work. Add
# "--verbose" here when debugging the proxy itself.
# A LISTENER, not a process that merely started. The server's
# entrypoint runs Ollama discovery immediately, and on the first
# wiring it raced ahead of this proxy binding -- discovery reported
# "Ollama unreachable" and found zero models, which is
# indistinguishable from Ollama being switched off. Compose starts
# dependencies first, but "started" is not "accepting connections";
# only a healthcheck closes that gap.
healthcheck:
test: ["CMD", "python3", "-c",
"import socket,sys; s=socket.create_connection(('127.0.0.1',8888),timeout=2); s.close()"]
interval: 2s
timeout: 3s
retries: 15
start_period: 2s
volumes:
- ${HARNESS_ROOT:-.}/scripts:/opt/capture/scripts:ro
- ${HARNESS_ROOT:-.}/results:/results
# Single, static, persistent opencode server. This is the whole point
# of the redesign: no more per-model image builds -- model selection
# is a request parameter now (server/routes/instance/httpapi/handlers
# /session.ts confirms providerID/modelID accepted per-request), not a
# Docker build arg. Started once, stays up, every eval run and every
# model under test talks to this same container over HTTP.
server:
image: opencode-model-eval-server:latest
container_name: opencode-model-eval-server
ulimits:
nproc: 8192
build:
context: .
dockerfile: Dockerfile
target: server
args:
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode}
OPENCODE_REF: ${OPENCODE_REF:-latest}
WORKER_UID: ${HOST_UID:-1000}
WORKER_GID: ${HOST_GID:-1000}
command: ["serve"]
# Only meaningful when capture is enabled; harmless otherwise, since
# an idle proxy still becomes healthy. This exists because the
# entrypoint's Ollama discovery runs before opencode starts and
# would otherwise race the proxy's bind.
depends_on:
capture:
condition: service_healthy
# 49605: this is the HOST-published port -- 4096 (container-internal,
# right side) risked colliding with Axiom's own separate opencode
# serve instance on the same host. Picked from IANA's dynamic/private
# port range (49152-65535, RFC 6335) to avoid registered-service
# collisions -- not verified against Axiom's actual chosen port,
# since that's outside this repo's config. Deliberately DIFFERENT
# from terraform/variables.tf's var.serve_port (49604) -- confirmed
# live that sharing one default meant Compose and Terraform could
# never both be up at once without a real port-bind collision, even
# though README's "Two deployment paths" section already frames
# both as legitimately simultaneous. Override via OPENCODE_SERVE_PORT
# if it still collides: `OPENCODE_SERVE_PORT=<port>
# docker-compose up`.
ports:
- "${OPENCODE_SERVE_PORT:-49605}:4096"
# Linux-only (matches the development host): routes host.docker.internal to the
# docker host's bridge gateway, since this container is on the
# default bridge network, not host mode. host-gateway only reaches
# services bound to 0.0.0.0 -- if Ollama is bound to 127.0.0.1 only
# (its default), this will NOT work until Ollama is started with
# OLLAMA_HOST=0.0.0.0:11434. Unverified beyond "resolves the
# networking path correctly on paper" -- no Docker/Ollama access in
# the environment this was authored in, same caveat as everything
# else in "Before first run" in the README.
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
# Read by entrypoint.sh, which starts as root, creates a matching
# passwd entry if the baked-in one does not fit, then drops to this
# uid:gid before exec'ing anything -- so files it writes to a bind
# mount are owned by the host user rather than root.
WORKER_UID: "${HOST_UID:-1000}"
WORKER_GID: "${HOST_GID:-1000}"
OPENCODE_OLLAMA_BASE_URL: "${OPENCODE_OLLAMA_BASE_URL:-http://host.docker.internal:11434/v1}"
OPENCODE_OLLAMA_TAGS_URL: "${OPENCODE_OLLAMA_TAGS_URL:-http://host.docker.internal:11434/api/tags}"
# session_reaper.py -- server-side TTL for sessions an abruptly
# disconnected client never aborted itself. See scripts/session_reaper.py's
# module docstring for why this exists (opencode has no native
# equivalent). Provider-scoped: local/ollama gets the aggressive
# 10min TTL (sustained Ollama residency is the actual cost this
# cuts), everything else gets the 60min fallback, which stays
# above run_eval_client.py's own 50min QUOTA_WAIT_THRESHOLD_S so
# it never preempts a legitimate cloud quota-retry wait.
OPENCODE_REAPER_ENABLED: "${OPENCODE_REAPER_ENABLED:-true}"
OPENCODE_LOCAL_SESSION_TTL_S: "${OPENCODE_LOCAL_SESSION_TTL_S:-600}"
OPENCODE_SESSION_TTL_S: "${OPENCODE_SESSION_TTL_S:-3600}"
OPENCODE_REAPER_POLL_INTERVAL_S: "${OPENCODE_REAPER_POLL_INTERVAL_S:-120}"
# PROVIDER-REQUEST CAPTURE, off unless asked for. Empty by default,
# so nothing sits in the inference path on a normal run: a bug in
# a proxy that every request traverses breaks every eval at once,
# rather than one check.
#
# Set OPENCODE_CAPTURE_PROXY=http://capture:8888 in .env and
# restart to record what opencode actually SENDS the provider --
# the resolved system array and tool definitions, which are
# assembled per request in session/prompt.ts and never persisted,
# so no endpoint and no message chain can yield them.
#
# Read by Bun's fetch at the runtime, NOT by opencode's own
# vendored getProxyForUrl (that is wired solely into
# plugin/openai/ws.ts). Confirmed live: the provider request
# arrives at the proxy in absolute-URI form, which only a
# proxy-aware client sends. Both spellings, since the lookup is
# case-insensitive in some paths and not others.
http_proxy: "${OPENCODE_CAPTURE_PROXY:-}"
HTTP_PROXY: "${OPENCODE_CAPTURE_PROXY:-}"
# https_proxy is REQUIRED SEPARATELY. Measured: with only
# http_proxy and all_proxy set, an https:// provider (opencode Zen)
# completed normally and produced zero capture records -- Bun did
# not fall back to all_proxy for the https scheme. A capture that
# silently records nothing for cloud models is exactly the false
# signal this harness exists to catch, so both spellings of both
# schemes are set explicitly rather than relying on a fallback.
https_proxy: "${OPENCODE_CAPTURE_PROXY:-}"
HTTPS_PROXY: "${OPENCODE_CAPTURE_PROXY:-}"
all_proxy: "${OPENCODE_CAPTURE_PROXY:-}"
ALL_PROXY: "${OPENCODE_CAPTURE_PROXY:-}"
# THE PROXY SETTING IS NOT SCOPED TO BUN. Every client in this
# container reads it, including Python's urllib -- so the
# entrypoint's own Ollama discovery and session_reaper.py's
# polling of localhost:4096 would both be routed through it.
# Confirmed the hard way: with the proxy briefly down, discovery
# reported "Ollama unreachable" and found zero models, which looks
# exactly like Ollama being off.
#
# Loopback is excluded because the reaper talks to this same
# container and that traffic is neither interesting nor safe to
# route through a hop. host.docker.internal is deliberately NOT
# excluded: provider traffic is the whole point of capturing.
# 0.0.0.0 is in the list because opencode calls ITSELF on it
# (observed: GET http://0.0.0.0:4096/session/...?directory=...),
# and routing a container's self-talk through a proxy on another
# container turns an internal call into a network dependency.
no_proxy: "${OPENCODE_CAPTURE_NO_PROXY:-localhost,127.0.0.1,0.0.0.0,::1,server}"
NO_PROXY: "${OPENCODE_CAPTURE_NO_PROXY:-localhost,127.0.0.1,0.0.0.0,::1,server}"
volumes:
- ${HARNESS_ROOT:-.}/auth-data/auth.json:/home/harness/.local/share/opencode/auth.json:ro
# Global opencode config -- your own local/ollama provider/model
# declarations, merged UNDER this project's own OPENCODE_CONFIG
# (confirmed via source: config.ts's loadGlobal() always loads this
# first, OPENCODE_CONFIG merges on top -- an override, not a
# replacement). See REQUIREMENTS.md for the format expected here.
# OPENCODE_GLOBAL_CONFIG must be an ABSOLUTE path, exported by your
# shell before running docker-compose -- e.g.:
# export OPENCODE_GLOBAL_CONFIG="$HOME/.config/opencode/opencode.json"
# Deliberately NOT defaulted to a `~`-prefixed path here: confirmed
# via real docker/compose issues (#6506, #3872) that tilde expansion
# in a volumes host path is inconsistent across compose versions and
# container-vs-native installs -- safer to require the shell (which
# always expands $HOME correctly) to hand compose an already-resolved
# absolute path than to trust compose's own expansion of `~`.
- ${OPENCODE_GLOBAL_CONFIG:-}:/home/harness/.config/opencode/opencode.json:ro
- opencode-log:/home/harness/.local/share/opencode/log
# PROVIDER SDK CACHE -- DESIGNED, NOT YET ENABLED. opencode installs
# a provider's npm package on first use (Npm.add into
# Global.Path.cache, core/src/npm.ts:79) and bun fetches the whole
# dependency tree in parallel: measured as a burst of ~190
# simultaneous TLS connections to registry.npmjs.org, repeating on
# every container recreate because nothing persists that directory.
#
# A named volume here does NOT work as-is and must not be re-added
# without the Dockerfile change: the image has no
# /home/harness/.cache directory for the volume to inherit
# ownership from, so Docker creates it root-owned and the dropped
# uid cannot write to it -- `EACCES: permission denied, mkdir
# '/home/harness/.cache/opencode'`, crash-looping the server.
# Enable by creating that path in the Dockerfile owned by
# WORKER_UID:WORKER_GID first, then mounting:
# - opencode-cache:/home/harness/.cache
#
# Not every provider pays this cost: @ai-sdk/openai-compatible is in
# opencode's BUNDLED_PROVIDERS map (provider.ts:113-133) and loads
# with no install at all, so local/ollama never triggers it -- the
# burst came from the cloud provider path.
restart: unless-stopped
healthcheck:
test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:4096/session', timeout=3)"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
# Resolves a model without needing the server running -- discovery
# uses `opencode models --verbose` directly (a standalone CLI command
# against provider config, confirmed not to require a running serve
# instance), not an HTTP call to the server service.
discover:
<<: *harness-defaults
entrypoint: ["python3", "/usr/local/bin/discover_and_select_model.py"]
# This service overrides entrypoint.sh entirely, so it never reaches
# the uid drop -- `user:` is how it writes to ./results as the host
# user instead. The passwd entry the numeric uid needs is baked into
# the image at build time, which is what makes a bare `user:` safe
# here (see the docker-run-as-host-user skill for why it is not,
# generally).
user: "${HOST_UID:-1000}:${HOST_GID:-1000}"
volumes:
- ${HARNESS_ROOT:-.}/auth-data/auth.json:/home/harness/.local/share/opencode/auth.json:ro
# Global opencode config -- your own local/ollama provider/model
# declarations, merged UNDER this project's own OPENCODE_CONFIG
# (confirmed via source: config.ts's loadGlobal() always loads this
# first, OPENCODE_CONFIG merges on top -- an override, not a
# replacement). See REQUIREMENTS.md for the format expected here.
# OPENCODE_GLOBAL_CONFIG must be an ABSOLUTE path, exported by your
# shell before running docker-compose -- e.g.:
# export OPENCODE_GLOBAL_CONFIG="$HOME/.config/opencode/opencode.json"
# Deliberately NOT defaulted to a `~`-prefixed path here: confirmed
# via real docker/compose issues (#6506, #3872) that tilde expansion
# in a volumes host path is inconsistent across compose versions and
# container-vs-native installs -- safer to require the shell (which
# always expands $HOME correctly) to hand compose an already-resolved
# absolute path than to trust compose's own expansion of `~`.
- ${OPENCODE_GLOBAL_CONFIG:-}:/home/harness/.config/opencode/opencode.json:ro
- ${HARNESS_ROOT:-.}/results:/results
# Read-write, not read-only: `opencode models --verbose` here is
# a raw CLI invocation (not a request to the persistent server),
# and the opencode CLI writes its own log file on ANY invocation,
# serve or not. Confirmed live: this hit the exact same
# "Unknown: FileSystem.open" error already fixed on
# docker_container.discover in terraform/main.tf and on
# scripts/tf-select-and-run-eval.sh's own discover invocation --
# this service is a third, independent copy of the same wrong
# assumption (that only `server` ever writes to this volume),
# not fixed by either of those other two fixes.
- opencode-log:/home/harness/.local/share/opencode/log
# For a specific model instead of auto-discovery:
# docker compose run --rm discover python3 /usr/local/bin/discover_and_select_model.py --model zhipu/glm-5.2
# Runs the structured test ladder against whatever
# OPENCODE_MODEL_PROVIDER/OPENCODE_MODEL_ID are set to, talking to the
# `server` service over HTTP -- "the rest is just API job." Depends_on
# only waits for the server container to start, not for it to actually
# be listening, so entrypoint.sh's eval-client mode polls before
# running (see entrypoint.sh).
eval:
<<: *harness-defaults
entrypoint: ["/usr/local/bin/entrypoint.sh"]
command: ["eval-client"]
depends_on:
server:
condition: service_healthy
# Linux-only (matches the development host): routes host.docker.internal to
# the docker host's bridge gateway. Needed for the local/ollama
# warm-up/unload mechanism's own direct /api/ps polling and unload
# call in run_eval_client.py -- separate from OPENCODE_SERVER_URL
# below, which reaches opencode's `server`, not Ollama directly.
# Harmless for cloud runs, which never read OLLAMA_BASE_URL at all.
extra_hosts:
- "host.docker.internal:host-gateway"
environment:
WORKER_UID: "${HOST_UID:-1000}"
WORKER_GID: "${HOST_GID:-1000}"
OPENCODE_SERVER_URL: "http://server:4096"
OLLAMA_BASE_URL: "${OLLAMA_BASE_URL:-http://host.docker.internal:11434}"
# Empty default, not a required (:?) interpolation -- :? is
# validated by docker-compose at BUILD time across the WHOLE
# file, not just this service, which breaks
# `docker-compose build` (and even `docker-compose build server`
# -- confirmed on the development host with docker-compose v1/legacy, which
# validates interpolation file-wide regardless of which service
# was named). The actual "did you forget to set this" check
# already exists at the right layer: run_eval_client.py's
# main() does `if not provider or not model_id: FATAL` before
# doing anything -- empty string here reaches that check
# correctly and fails loudly at RUN time, not BUILD time.
OPENCODE_MODEL_PROVIDER: ${OPENCODE_MODEL_PROVIDER:-}
OPENCODE_MODEL_ID: ${OPENCODE_MODEL_ID:-}
volumes:
- ${HARNESS_ROOT:-.}/task-suite:/task-suite:ro
- ${HARNESS_ROOT:-.}/auth-data/auth.json:/home/harness/.local/share/opencode/auth.json:ro
# Global opencode config -- your own local/ollama provider/model
# declarations, merged UNDER this project's own OPENCODE_CONFIG
# (confirmed via source: config.ts's loadGlobal() always loads this
# first, OPENCODE_CONFIG merges on top -- an override, not a
# replacement). See REQUIREMENTS.md for the format expected here.
# OPENCODE_GLOBAL_CONFIG must be an ABSOLUTE path, exported by your
# shell before running docker-compose -- e.g.:
# export OPENCODE_GLOBAL_CONFIG="$HOME/.config/opencode/opencode.json"
# Deliberately NOT defaulted to a `~`-prefixed path here: confirmed
# via real docker/compose issues (#6506, #3872) that tilde expansion
# in a volumes host path is inconsistent across compose versions and
# container-vs-native installs -- safer to require the shell (which
# always expands $HOME correctly) to hand compose an already-resolved
# absolute path than to trust compose's own expansion of `~`.
- ${OPENCODE_GLOBAL_CONFIG:-}:/home/harness/.config/opencode/opencode.json:ro
- ${HARNESS_ROOT:-.}/results:/results
- opencode-log:/home/harness/.local/share/opencode/log:ro
# One-shot container for tasks that genuinely need git (e.g. cloning
# a repo to inspect or work on it, instead of the web-fetch-flood
# workaround this was replacing) plus normal dev-workflow commands
# against whatever gets cloned (npm/pip/build/test -- not narrowed to
# git specifically). Deliberately isolated from every other service:
# no /task-suite, no /results, no opencode-log volume -- nothing
# mounted here persists past this container's lifetime or is shared
# with any other role, so bash/edit being allowed broadly still has
# nothing to reach beyond its own throwaway filesystem. Run with
# `docker-compose run --rm git-workspace`, not `up` -- it is not
# meant to be long-lived like `server`.
git-workspace:
<<: *harness-defaults
build:
context: .
dockerfile: Dockerfile
target: harness
args:
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode}
OPENCODE_REF: ${OPENCODE_REF:-latest}
WORKER_UID: ${HOST_UID:-1000}
WORKER_GID: ${HOST_GID:-1000}
entrypoint: ["/usr/local/bin/entrypoint.sh", "serve"]
environment:
WORKER_UID: "${HOST_UID:-1000}"
WORKER_GID: "${HOST_GID:-1000}"
OPENCODE_CONFIG: /opt/harness/opencode.git-workspace.json
OPENCODE_SERVE_PORT: "4096"
# Confirmed missing: without this, nothing could reach this
# container's opencode serve instance at all, regardless of model
# selection. Model selection needs no config here -- same
# mechanism as the `server` service (which also never sets
# OPENCODE_MODEL and works fine live): whatever connects specifies
# provider/model per-session, exactly like run_eval_client.py
# already does against server.
ports:
- "${GIT_WORKSPACE_PORT:-49606}:4096"
volumes:
- ${HARNESS_ROOT:-.}/auth-data/auth.json:/home/harness/.local/share/opencode/auth.json:ro
# Global opencode config -- your own local/ollama provider/model
# declarations, merged UNDER this project's own OPENCODE_CONFIG
# (confirmed via source: config.ts's loadGlobal() always loads this
# first, OPENCODE_CONFIG merges on top -- an override, not a
# replacement). See REQUIREMENTS.md for the format expected here.
# OPENCODE_GLOBAL_CONFIG must be an ABSOLUTE path, exported by your
# shell before running docker-compose -- e.g.:
# export OPENCODE_GLOBAL_CONFIG="$HOME/.config/opencode/opencode.json"
# Deliberately NOT defaulted to a `~`-prefixed path here: confirmed
# via real docker/compose issues (#6506, #3872) that tilde expansion
# in a volumes host path is inconsistent across compose versions and
# container-vs-native installs -- safer to require the shell (which
# always expands $HOME correctly) to hand compose an already-resolved
# absolute path than to trust compose's own expansion of `~`.
- ${OPENCODE_GLOBAL_CONFIG:-}:/home/harness/.config/opencode/opencode.json:ro
# Persistent Jupyter server for hand-authoring/debugging custom-test
# notebooks (item 9 of the pinned backlog) -- NOT the headless
# papermill execution path (still not built, see Dockerfile's jupyter
# stage comment). Like `server`, this is meant to be started/stopped
# explicitly (see harness-control.sh's "Start/Stop Jupyter" menu
# entries and `make jupyter-up`/`jupyter-down`), not run per-task.
# Notebook storage is a host bind mount (./notebooks, gitignored) --
# NOT a Docker-managed named volume like opencode-log. Confirmed live
# that a named volume genuinely doesn't appear anywhere in the
# repo's host filesystem by design; that's fine for an internal log
# nobody needs to browse directly, but wrong for notebooks someone
# actually wants to find, open, or back up on the host.
jupyter:
# Long-running, but not part of the default stack: an eval run does
# not need it, and a bare `up -d` should not start an authoring
# server nobody asked for. `make jupyter-up` names it explicitly and
# is unaffected.
profiles: ["jupyter"]
image: opencode-model-eval-jupyter:latest
container_name: opencode-model-eval-jupyter
ulimits:
nproc: 8192
build:
context: .
dockerfile: Dockerfile
target: jupyter
args:
OPENCODE_IMAGE: ${OPENCODE_IMAGE:-ghcr.io/anomalyco/opencode}
OPENCODE_REF: ${OPENCODE_REF:-latest}
WORKER_UID: ${HOST_UID:-1000}
WORKER_GID: ${HOST_GID:-1000}
environment:
# The role that writes to a host bind mount most often, once per
# notebook save.
WORKER_UID: "${HOST_UID:-1000}"
WORKER_GID: "${HOST_GID:-1000}"
ports:
- "${JUPYTER_PORT:-8888}:8888"
volumes:
- ${HARNESS_ROOT:-.}/notebooks:/notebooks
restart: unless-stopped
# Shared between server (rw) and every client container (ro) -- lets
# run_eval_client.py capture opencode's own server-side log as a
# results artifact (server.log), which has the REAL underlying error
# (e.g. ProviderModelNotFoundError) behind a generic client-visible
# HTTP 500 wrapper. NOT a "previously invisible" fix -- --print-logs
# (entrypoint.sh) already mirrors this to stderr, so `docker logs
# server` shows it live. What this actually adds: docker logs belongs
# to the daemon, tied to the server container -- eval/discover have
# no access to it, and it isn't scoped to any one run. This volume
# makes it a per-run file artifact instead. Docker-managed (not a
# host bind-mount) since the log only ever needs to exist inside the
# container network, never on the host filesystem directly.
volumes:
opencode-log: