源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)

This commit is contained in:
agent-radar
2026-09-02 01:05:41 +08:00
commit 04bac1f6e2
2200 changed files with 489415 additions and 0 deletions
+56
View File
@@ -0,0 +1,56 @@
FROM node:22-bookworm-slim
ARG OPENWORK_SERVER_VERSION=0.18.4
ARG OPENCODE_VERSION=1.17.11
ARG OPENCODE_DOWNLOAD_URL=
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ca-certificates \
curl \
git \
tar \
unzip \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g "openwork-server@${OPENWORK_SERVER_VERSION}"
RUN set -eux; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) asset="opencode-linux-x64-baseline.tar.gz" ;; \
arm64) asset="opencode-linux-arm64.tar.gz" ;; \
*) echo "unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \
url="$OPENCODE_DOWNLOAD_URL"; \
if [ -z "$url" ]; then \
url="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/${asset}"; \
fi; \
tmpdir="$(mktemp -d)"; \
curl -fsSL "$url" -o "$tmpdir/$asset"; \
tar -xzf "$tmpdir/$asset" -C "$tmpdir"; \
binary="$(find "$tmpdir" -type f -name opencode | head -n 1)"; \
test -n "$binary"; \
install -m 0755 "$binary" /usr/local/bin/opencode; \
rm -rf "$tmpdir"
# Persistent directories (mount volumes here on PaaS/SSH).
# The data path deliberately keeps its historical name: existing deployments
# have volumes mounted at /data/openwork-orchestrator, and renaming it here
# would silently orphan their state. The orchestrator CLI itself is gone; this
# is only a directory name kept for compatibility.
ENV OPENWORK_DATA_DIR=/data/openwork-orchestrator
ENV OPENWORK_SIDECAR_DIR=/data/sidecars
# The workspace is mounted from the host/volume.
ENV OPENWORK_WORKSPACE=/workspace
# OpenWork host contract surface.
EXPOSE 8787
VOLUME ["/workspace", "/data"]
# Defaults:
# - OpenWork server is published intentionally via --host 0.0.0.0
# - OpenCode is managed by openwork-server and stays internal
CMD ["/bin/sh", "-lc", "plugin_dir=\"$(npm root -g)/openwork-server/dist/opencode-plugins\"; test -d \"$plugin_dir\"; export OPENWORK_MANAGE_OPENCODE=1 OPENWORK_OPENCODE_BIN=/usr/local/bin/opencode OPENWORK_EXTENSIONS_PLUGIN_DIR=\"$plugin_dir\"; exec openwork-server --workspace \"${OPENWORK_WORKSPACE:-/workspace}\" --host 0.0.0.0 --port \"${OPENWORK_PORT:-8787}\" --cors \"${OPENWORK_CORS_ORIGINS:-*}\" --approval \"${OPENWORK_APPROVAL_MODE:-manual}\""]
+92
View File
@@ -0,0 +1,92 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ make python3 \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
WORKDIR /app
ARG DEN_API_VERSION=dev
ENV DEN_API_VERSION=${DEN_API_VERSION}
ENV PORT=8788
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/
COPY patches /app/patches
COPY packages/types/package.json /app/packages/types/package.json
COPY packages/automations/package.json /app/packages/automations/package.json
COPY packages/codemode/package.json /app/packages/codemode/package.json
COPY packages/email/package.json /app/packages/email/package.json
COPY packages/enterprise-mcp-client/package.json /app/packages/enterprise-mcp-client/package.json
COPY packages/install-config/package.json /app/packages/install-config/package.json
COPY packages/connect-link/package.json /app/packages/connect-link/package.json
COPY packages/headless-threads/package.json /app/packages/headless-threads/package.json
COPY packages/mcp-apps/package.json /app/packages/mcp-apps/package.json
# apps/desktop/package.json is copied below for version metadata, and it depends
# on @openwork/paths, so the package must exist for workspace resolution.
COPY packages/paths/package.json /app/packages/paths/package.json
# The root workspace declares the shared evaluator helpers as dev dependencies.
# Their manifests must be present even though the Den API image does not build
# or ship their source.
COPY evals/packages/behaviors/package.json /app/evals/packages/behaviors/package.json
COPY evals/packages/cdp/package.json /app/evals/packages/cdp/package.json
COPY evals/packages/labs/package.json /app/evals/packages/labs/package.json
COPY evals/packages/matchers/package.json /app/evals/packages/matchers/package.json
COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json
COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json
COPY ee/packages/telemetry-contracts/package.json /app/ee/packages/telemetry-contracts/package.json
COPY ee/packages/telemetry/package.json /app/ee/packages/telemetry/package.json
COPY ee/apps/den-api/package.json /app/ee/apps/den-api/package.json
RUN pnpm install --frozen-lockfile --trust-lockfile
COPY packages/types /app/packages/types
COPY packages/automations /app/packages/automations
COPY packages/codemode /app/packages/codemode
COPY packages/email /app/packages/email
COPY packages/enterprise-mcp-client /app/packages/enterprise-mcp-client
COPY packages/install-config /app/packages/install-config
COPY packages/connect-link /app/packages/connect-link
COPY packages/headless-threads /app/packages/headless-threads
COPY packages/mcp-apps /app/packages/mcp-apps
COPY packages/paths /app/packages/paths
COPY ee/packages/utils /app/ee/packages/utils
COPY ee/packages/den-db /app/ee/packages/den-db
COPY ee/packages/telemetry-contracts /app/ee/packages/telemetry-contracts
COPY ee/packages/telemetry /app/ee/packages/telemetry
COPY ee/apps/den-api /app/ee/apps/den-api
COPY apps/desktop/package.json /app/apps/desktop/package.json
# Source-map upload is build-only; runtime Sentry still requires its own backend and DSN configuration.
ARG DEN_UPLOAD_SENTRY_SOURCEMAPS=0
ARG DEN_SENTRY_UPLOAD_BUILD_ID=disabled
RUN --mount=type=secret,id=sentry_auth_token,required=false \
--mount=type=secret,id=sentry_org,required=false \
--mount=type=secret,id=sentry_project,required=false \
--mount=type=secret,id=sentry_url,required=false \
--mount=type=secret,id=sentry_release,required=false \
--mount=type=secret,id=sentry_dist,required=false \
set -eu; \
if [ "$DEN_UPLOAD_SENTRY_SOURCEMAPS" = "1" ]; then \
if [ ! -s /run/secrets/sentry_auth_token ] || [ ! -s /run/secrets/sentry_org ] || [ ! -s /run/secrets/sentry_project ] || [ ! -s /run/secrets/sentry_release ]; then \
echo "Sentry source-map upload requested but required BuildKit secrets are incomplete" >&2; \
exit 1; \
fi; \
export DEN_UPLOAD_SENTRY_SOURCEMAPS; \
export SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token)"; \
export SENTRY_ORG="$(cat /run/secrets/sentry_org)"; \
export SENTRY_PROJECT="$(cat /run/secrets/sentry_project)"; \
export SENTRY_RELEASE="$(cat /run/secrets/sentry_release)"; \
if [ -s /run/secrets/sentry_url ]; then export SENTRY_URL="$(cat /run/secrets/sentry_url)"; fi; \
if [ -s /run/secrets/sentry_dist ]; then export SENTRY_DIST="$(cat /run/secrets/sentry_dist)"; fi; \
: "$DEN_SENTRY_UPLOAD_BUILD_ID"; \
fi; \
pnpm --dir /app/ee/apps/den-api run build
EXPOSE 8788
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || '8788') + '/health').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "/app/ee/apps/den-api/dist/main.js"]
+63
View File
@@ -0,0 +1,63 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
RUN corepack enable
WORKDIR /app
ARG DEN_GATEWAY_VERSION=""
ENV DEN_GATEWAY_VERSION=${DEN_GATEWAY_VERSION}
ENV PORT=8788
# The gateway serves the OpenWork web build itself, so the app and Den end up on
# one origin. That is what removes the need for cross-origin patches, and it is
# why the SPA is baked in here rather than served from a separate CDN origin.
ENV DEN_GATEWAY_WEB_ROOT=/opt/openwork/web
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/
COPY patches /app/patches
# This image installs only the gateway runtime workspace. Some root patches apply
# to packages that are intentionally not copied here, and pnpm rejects those as
# unused unless this pruned install opts out of that check.
RUN node -e "const fs = require('node:fs'); fs.appendFileSync('/app/pnpm-workspace.yaml', '\nallowUnusedPatches: true\n')"
COPY packages/types/package.json /app/packages/types/package.json
COPY packages/ui/package.json /app/packages/ui/package.json
COPY packages/install-config/package.json /app/packages/install-config/package.json
COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json
COPY ee/apps/den-gateway/package.json /app/ee/apps/den-gateway/package.json
COPY apps/app/package.json /app/apps/app/package.json
RUN pnpm install --frozen-lockfile --trust-lockfile
COPY packages/types /app/packages/types
COPY packages/ui /app/packages/ui
COPY packages/install-config /app/packages/install-config
COPY ee/packages/utils /app/ee/packages/utils
COPY ee/apps/den-gateway /app/ee/apps/den-gateway
COPY apps/app /app/apps/app
RUN pnpm --dir /app/packages/types run build
RUN pnpm --dir /app/packages/ui run build
RUN pnpm --dir /app/packages/install-config run build
RUN pnpm --dir /app/ee/packages/utils run build
# build:web already sets VITE_OPENWORK_DEPLOYMENT=web and
# VITE_DEN_REQUIRE_SIGNIN=1. VITE_DEN_BASE_URL is deliberately NOT baked: in
# gateway mode the app resolves Den from window.location.origin at runtime via
# the gateway marker, and a baked base URL would silently defeat that.
RUN pnpm --dir /app/apps/app run build:web
RUN pnpm --dir /app/ee/apps/den-gateway run build
RUN mkdir -p /opt/openwork \
&& cp -R /app/apps/app/dist /opt/openwork/web \
&& test -f /opt/openwork/web/index.html \
&& test -d /opt/openwork/web/assets
EXPOSE 8788
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || '8788') + '/__gw/health').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "/app/ee/apps/den-gateway/dist/server.js"]
+66
View File
@@ -0,0 +1,66 @@
# syntax=docker/dockerfile:1.7
FROM node:22-bookworm-slim
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/
COPY patches /app/patches
COPY packages/types/package.json /app/packages/types/package.json
COPY packages/ui/package.json /app/packages/ui/package.json
COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json
# Client-safe telemetry contracts: source-only package (no build step) that
# den-web compiles through transpilePackages. Keep the server-side
# @openwork-ee/telemetry package (and its den-db graph) out of this image.
COPY ee/packages/telemetry-contracts/package.json /app/ee/packages/telemetry-contracts/package.json
COPY ee/apps/den-web/package.json /app/ee/apps/den-web/package.json
RUN pnpm install --frozen-lockfile --trust-lockfile --filter @openwork-ee/den-web...
COPY packages/types /app/packages/types
COPY packages/ui /app/packages/ui
COPY ee/packages/utils /app/ee/packages/utils
COPY ee/packages/telemetry-contracts /app/ee/packages/telemetry-contracts
COPY ee/apps/den-web /app/ee/apps/den-web
# @openwork-ee/utils resolves to dist/ in production builds and nothing else in
# this image builds it; without this step a cold (cache-miss) image build fails
# in next build with "Can't resolve '@openwork-ee/utils'".
RUN pnpm --dir /app/ee/packages/utils run build
WORKDIR /app/ee/apps/den-web
# Source-map upload is build-only; runtime Sentry still requires its own backend and DSN configuration.
ARG DEN_WEB_UPLOAD_SENTRY_SOURCEMAPS=0
ARG DEN_WEB_SENTRY_UPLOAD_BUILD_ID=disabled
RUN --mount=type=secret,id=sentry_auth_token,required=false \
--mount=type=secret,id=sentry_org,required=false \
--mount=type=secret,id=sentry_project,required=false \
--mount=type=secret,id=sentry_url,required=false \
--mount=type=secret,id=sentry_release,required=false \
--mount=type=secret,id=sentry_dist,required=false \
set -eu; \
export DEN_WEB_UPLOAD_SENTRY_SOURCEMAPS; \
case "$DEN_WEB_UPLOAD_SENTRY_SOURCEMAPS" in \
1|[Tt][Rr][Uu][Ee]|[Yy][Ee][Ss]|[Oo][Nn]) \
if [ ! -s /run/secrets/sentry_auth_token ] || [ ! -s /run/secrets/sentry_org ] || [ ! -s /run/secrets/sentry_project ]; then \
echo "Den Web Sentry source-map upload requested but required BuildKit secrets are incomplete" >&2; \
exit 1; \
fi; \
export SENTRY_AUTH_TOKEN="$(cat /run/secrets/sentry_auth_token)"; \
export SENTRY_ORG="$(cat /run/secrets/sentry_org)"; \
export SENTRY_PROJECT="$(cat /run/secrets/sentry_project)"; \
if [ -s /run/secrets/sentry_url ]; then export SENTRY_URL="$(cat /run/secrets/sentry_url)"; fi; \
if [ -s /run/secrets/sentry_release ]; then export SENTRY_RELEASE="$(cat /run/secrets/sentry_release)"; fi; \
if [ -s /run/secrets/sentry_dist ]; then export SENTRY_DIST="$(cat /run/secrets/sentry_dist)"; fi; \
: "$DEN_WEB_SENTRY_UPLOAD_BUILD_ID"; \
;; \
esac; \
pnpm run build
EXPOSE 3005
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || '3005') + '/api/health').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["sh", "-lc", "/app/ee/apps/den-web/node_modules/.bin/next start --hostname 0.0.0.0 --port ${PORT:-3005}"]
+37
View File
@@ -0,0 +1,37 @@
FROM node:22-bookworm-slim
RUN apt-get update \
&& apt-get install -y --no-install-recommends g++ make python3 \
&& rm -rf /var/lib/apt/lists/*
RUN corepack enable
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=8791
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/
COPY patches /app/patches
COPY packages/types/package.json /app/packages/types/package.json
COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json
COPY ee/packages/den-db/package.json /app/ee/packages/den-db/package.json
COPY ee/apps/inference/package.json /app/ee/apps/inference/package.json
RUN pnpm install --frozen-lockfile --trust-lockfile
COPY packages/types /app/packages/types
COPY ee/packages/utils /app/ee/packages/utils
COPY ee/packages/den-db /app/ee/packages/den-db
COPY ee/apps/inference /app/ee/apps/inference
RUN pnpm --dir /app/packages/types run build
RUN pnpm --dir /app/ee/packages/utils run build
RUN pnpm --dir /app/ee/packages/den-db run build
RUN pnpm --dir /app/ee/apps/inference run build
EXPOSE 8791
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 CMD node -e "fetch('http://127.0.0.1:' + (process.env.PORT || '8791') + '/health').then((res) => process.exit(res.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "--import", "/app/ee/apps/inference/dist/instrumentation.js", "/app/ee/apps/inference/dist/server.js"]
+74
View File
@@ -0,0 +1,74 @@
FROM node:22-bookworm-slim AS openwork-builder
WORKDIR /src
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git unzip \
&& npm install -g bun@1.3.14 \
&& corepack enable \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN pnpm install --frozen-lockfile --trust-lockfile --filter openwork-server... \
&& pnpm --filter openwork-server build \
&& pnpm --filter openwork-server build:bin
FROM node:22-bookworm-slim
ARG OPENWORK_SERVER_VERSION
ARG OPENCODE_VERSION
ARG OPENCODE_DOWNLOAD_URL=
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl tar unzip \
&& rm -rf /var/lib/apt/lists/*
COPY --from=openwork-builder /src/apps/server/dist/bin/openwork-server /usr/local/bin/openwork-server
COPY --from=openwork-builder /src/apps/server/dist/opencode-plugins /opt/openwork/opencode-plugins
COPY --from=openwork-builder /src/constants.json /usr/local/constants.json
COPY packaging/docker/microsandbox-entrypoint.sh /usr/local/bin/microsandbox-entrypoint.sh
RUN set -eux; \
test -n "$OPENWORK_SERVER_VERSION"; \
test -n "$OPENCODE_VERSION"; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) asset="opencode-linux-x64-baseline.tar.gz" ;; \
arm64) asset="opencode-linux-arm64.tar.gz" ;; \
*) echo "unsupported architecture: $arch" >&2; exit 1 ;; \
esac; \
url="$OPENCODE_DOWNLOAD_URL"; \
if [ -z "$url" ]; then \
url="https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/${asset}"; \
fi; \
tmpdir="$(mktemp -d)"; \
curl -fsSL "$url" -o "$tmpdir/$asset"; \
tar -xzf "$tmpdir/$asset" -C "$tmpdir"; \
binary="$(find "$tmpdir" -type f -name opencode | head -n 1)"; \
test -n "$binary"; \
install -m 0755 "$binary" /usr/local/bin/opencode; \
chmod +x /usr/local/bin/microsandbox-entrypoint.sh; \
rm -rf "$tmpdir"
RUN test -f /opt/openwork/opencode-plugins/openwork-extensions-preview.js \
&& test -f /opt/openwork/opencode-plugins/openwork-capabilities-knowledge.js \
&& test -f /opt/openwork/opencode-plugins/openwork-office-attachments.js \
&& test -f /opt/openwork/opencode-plugins/openwork-anthropic-adaptive-thinking.js \
&& test -f /opt/openwork/opencode-plugins/openwork-anthropic-tool-schema.js \
&& test "$(openwork-server --version)" = "$OPENWORK_SERVER_VERSION" \
&& opencode --version
ENV OPENWORK_EXTENSIONS_PLUGIN_DIR=/opt/openwork/opencode-plugins
ENV OPENWORK_DATA_DIR=/data/openwork-server
ENV OPENWORK_SIDECAR_DIR=/data/sidecars
ENV OPENWORK_WORKSPACE=/workspace
EXPOSE 8787
VOLUME ["/workspace", "/data"]
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=12 \
CMD /bin/sh -c 'curl -fsS "http://127.0.0.1:${OPENWORK_PORT:-8787}/health" >/dev/null || exit 1'
ENTRYPOINT ["/usr/local/bin/microsandbox-entrypoint.sh"]
+401
View File
@@ -0,0 +1,401 @@
# OpenWork Host (Docker)
## Pull-only evaluation stack
Run Den API, Den web, and MySQL from published images without cloning or building the repository:
```bash
curl -fsSLo docker-compose.eval.yml \
https://raw.githubusercontent.com/different-ai/openwork/9f8645ebc482c15ab99c0cf155aabaa411e1ca6a/packaging/docker/docker-compose.eval.yml
printf '%s %s\n' \
'69cc7f2666157b7697ebf69b31b0c83887dd99e796c7d956f9ccaab8fa8bf2fc' \
'docker-compose.eval.yml' | shasum -a 256 --check
umask 077
printf 'OPENWORK_AUTH_SECRET=%s\nOPENWORK_DB_ENCRYPTION_KEY=%s\n' \
"$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env
docker compose -f docker-compose.eval.yml up -d --wait
```
Open `http://localhost:3005` and create an account. See the [evaluation guide](../../packages/docs/self-host/evaluate-with-docker-compose.mdx) for configuration, secure remote access, limitations, and cleanup.
This stack is for evaluation only. Its HTTP ports bind to loopback, but it still enables public signup, uses development credentials, and does not provide production workers, sandboxes, or email delivery.
## Den local stack (Docker)
One command for the Den control plane, local MySQL, and the cloud web app.
From the repo root:
```bash
./packaging/docker/den-dev-up.sh
```
Or via pnpm:
```bash
pnpm dev:den-docker
```
What it does:
- Starts **MySQL** for the Den service
- Starts **Den control plane** on port 8788 inside Docker with `PROVISIONER_MODE=stub`
- Runs **Den migrations** automatically before the API starts in the local compose stack
- Starts the **OpenWork Cloud web app** on port 3005 inside Docker
- Points the web app's auth + API proxy routes at the local Den service
- Prints randomized host URLs so multiple stacks can run side by side
Production-oriented EE images:
- `Dockerfile.den` -> `ghcr.io/different-ai/openwork-den-api`
- `Dockerfile.den-web` -> `ghcr.io/different-ai/openwork-den-web`
- `Dockerfile.inference` -> `ghcr.io/different-ai/openwork-inference`
These images are intended for Terraform, Helm, ECS, EKS, and customer-cloud deployments. Prefer immutable tags or digests in production.
Publish flow:
- Release tags like `v0.17.1` publish images tagged `v0.17.1`, `0.17.1`, `sha-<commit>`, and `latest`.
- The same workflow publishes the Helm chart to `oci://ghcr.io/different-ai/charts/openwork-ee` with chart version `0.17.1`.
- Manual publishes from a branch require `push=true` and an explicit `chart_version`.
Health and smoke expectations:
- Published service images include shallow Docker healthchecks for the HTTP process only.
- Den API and inference probe `GET /health`; Den web probes `GET /api/health`.
- The publish workflow loads each PR image locally and probes the same endpoint without cloud secrets. Production deployments should still add dependency-aware readiness checks where needed.
### Demo org seed
After the Den DB is running, seed a full local demo org with users, teams, pending invites, and imported plugin data from `anthropics/knowledge-work-plugins`:
```bash
pnpm dev:den:seed-demo
```
The seed is local/dev-only, idempotent for the `acme-robotics-demo` org, and does not create workers or live integrations. It imports plugin marketplace rows, plugin rows, access grants, and config objects so plugin pages look populated without connecting external services.
Default demo login:
- Email: `alex@acme.test`
- Password: `OpenWorkDemo123!`
For the Docker stack with randomized MySQL ports, source the printed runtime env file first and pass `DEN_MYSQL_URL` as `DATABASE_URL`:
```bash
source tmp/.den-dev-env-<id>
DATABASE_URL="$DEN_MYSQL_URL" pnpm dev:den:seed-demo
```
Set `DEN_DEMO_SEED_FETCH_GITHUB=0` to skip live GitHub source fetching and use built-in plugin fallbacks only.
Useful commands:
- Logs: `docker compose -p <project> -f packaging/docker/docker-compose.den-dev.yml logs`
- Tear down: `docker compose -p <project> -f packaging/docker/docker-compose.den-dev.yml down`
- Tear down + reset DB: `docker compose -p <project> -f packaging/docker/docker-compose.den-dev.yml down -v`
Optional env vars (via `.env` or `export`):
- `DEN_API_PORT` — host port to map to the Den control plane :8788
- `DEN_WEB_PORT` — host port to map to the cloud web app :3005
- `DEN_BETTER_AUTH_SECRET` — Better Auth secret (auto-generated if unset)
- `DEN_PUBLIC_HOST` — host name/IP used for default auth URL + printed LAN/public URLs (defaults to your machine hostname)
- `DEN_BETTER_AUTH_URL` — browser-facing auth base URL (defaults to `http://$DEN_PUBLIC_HOST:<DEN_WEB_PORT>`)
- `DEN_MCP_RESOURCE_URL` — API-facing MCP resource URL (defaults to `http://localhost:<DEN_API_PORT>/mcp`)
- `DEN_MCP_ADDITIONAL_RESOURCES` — extra public MCP resource URLs beyond the `DEN_API_PUBLIC_URL` API origin and web-app defaults
- `DEN_BETTER_AUTH_TRUSTED_ORIGINS` — trusted origins for Better Auth (defaults to `DEN_CORS_ORIGINS`)
- `DEN_CORS_ORIGINS` — trusted origins for Express CORS (defaults include hostname, localhost, `127.0.0.1`, `0.0.0.0`, and detected LAN IPv4)
- `DEN_PROVISIONER_MODE` — `stub` or `render` (defaults to `stub`)
- `DEN_WORKER_URL_TEMPLATE` — stub worker URL template with `{workerId}` placeholder
### Live OpenTelemetry Docker validation
`docker-compose.otel-lgtm.yml` adds Grafana's all-in-one development backend
(Grafana, Tempo, Loki, Prometheus, Pyroscope, and an OTLP receiver) to the Den
Docker stack. The image is pinned to `0.11.16` and its multi-arch digest.
Start only the collector/UI stack:
```bash
docker compose -f packaging/docker/docker-compose.otel-lgtm.yml up -d --wait
docker compose -f packaging/docker/docker-compose.otel-lgtm.yml ps
curl -fsS http://127.0.0.1:3000/api/health
```
Run the reproducible live E2E validator (the script resolves paths relative to
itself, so absolute-path invocations work from any working directory):
```bash
bash packaging/docker/otel-hono-live-validate.sh
```
The validator merges `docker-compose.den-dev.yml` and
`docker-compose.otel-lgtm.yml`, configures both Den services for OTLP/HTTP,
builds and starts the stack, waits for health, and makes a real request to
`/api/den/openapi.json?token=super-secret`. It then polls Grafana's Tempo,
Loki, and Prometheus datasource proxy APIs with a bounded timeout. The JSON
report proves the connected `den-web` → `den-api` trace, normalized Hono route
and request ID, secret redaction, correlated Den Web and Den API OTLP request
logs, Hono duration and active-request metric series, and absence of unhandled
runtime errors.
After the OTEL phase passes, the same validator recreates only `den` and `web`
with `DEN_OBSERVABILITY_BACKEND=none` while keeping MySQL and LGTM running. It
makes the same proxy request and records proof that the app still returns HTTP
200, request logs are structured JSON stdout for both services, and no exporter
connection/error attempt appears in the none-backend runtime logs.
Defaults use a unique Compose project and process-derived high host ports, so
parallel runs do not share state. Useful controls:
```bash
KEEP_STACK=1 bash packaging/docker/otel-hono-live-validate.sh # keep containers (left in none mode after the final phase)
OTEL_HONO_PROJECT=<previous-project> SKIP_BUILD=1 bash packaging/docker/otel-hono-live-validate.sh
OTEL_HONO_PORT_BASE=42000 bash packaging/docker/otel-hono-live-validate.sh
OTEL_HONO_REPORT_JSON=/tmp/otel-proof.json bash packaging/docker/otel-hono-live-validate.sh
```
Individual host ports remain overridable with `DEN_WEB_PORT`, `DEN_API_PORT`,
`DEN_MYSQL_PORT`, `OTEL_LGTM_GRAFANA_PORT`,
`OTEL_LGTM_OTLP_GRPC_PORT`, and `OTEL_LGTM_OTLP_HTTP_PORT`. Override
`OTEL_HONO_PROJECT`, `OTEL_HONO_POLL_SECONDS`, or
`OTEL_HONO_COMPOSE_WAIT_SECONDS` when a stable project or different bounded
timeouts are needed. The default report is
`tmp/otel-hono-live-<project>/report.json`.
Open Grafana at `http://127.0.0.1:3000` and log in with `admin` / `admin`. Use Explore with the pre-provisioned Tempo, Loki, and Prometheus data sources for traces, logs, and metrics.
To start the same merged stack manually:
```bash
DEN_OBSERVABILITY_BACKEND=otel \
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-lgtm:4318 \
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=http://otel-lgtm:4318/v1/traces \
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT=http://otel-lgtm:4318/v1/metrics \
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=http://otel-lgtm:4318/v1/logs \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
OTEL_TRACES_EXPORTER=otlp \
OTEL_METRICS_EXPORTER=otlp \
OTEL_LOGS_EXPORTER=otlp \
OTEL_TRACES_SAMPLER=parentbased_always_on \
docker compose -p openwork-den-otel \
-f packaging/docker/docker-compose.den-dev.yml \
-f packaging/docker/docker-compose.otel-lgtm.yml \
up --build --wait
```
Run the services outside Docker against the same local receiver:
```bash
docker compose -f packaging/docker/docker-compose.otel-lgtm.yml up -d --wait
DEN_OBSERVABILITY_BACKEND=otel \
OTEL_SERVICE_NAME=den-api \
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
pnpm dev:den:api
DEN_OBSERVABILITY_BACKEND=otel \
OTEL_SERVICE_NAME=den-web \
OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4318 \
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \
pnpm dev:den:web
```
Useful signal controls:
```bash
OTEL_TRACES_EXPORTER=none # disable traces
OTEL_METRICS_EXPORTER=none # disable metrics
OTEL_LOGS_EXPORTER=none # disable logs
OTEL_TRACES_SAMPLER=parentbased_traceidratio OTEL_TRACES_SAMPLER_ARG=0.25
```
`docker-compose.den-dev.yml` forwards the full OTLP runtime surface to both Den
services: base and per-signal endpoints, base and per-signal protocol values,
headers, per-signal exporters, and sampler settings. It also forwards runtime
Sentry settings (`SENTRY_DSN`, `SENTRY_TRACES_SAMPLE_RATE`, `SENTRY_LOG_LEVEL`,
`SENTRY_ENVIRONMENT`, `SENTRY_RELEASE`, and `SENTRY_DIST`) while keeping
distinct `OTEL_SERVICE_NAME` defaults for Den API and Den Web. Sentry tracing
defaults to `0.01`; `SENTRY_LOG_LEVEL` defaults to `warn` so successful request
logs stay on stdout unless explicitly enabled for Sentry.
Sentry source-map upload is build-time only. Runtime Compose and Helm settings
cannot upload maps after the image is built. The Dockerfiles default their
upload flags to `0`, so normal builds and PR builds skip Sentry secret reads
cleanly. To opt in for a trusted build, pass only non-secret upload flags as
build args and pass credentials as BuildKit secrets; never pass Sentry
credentials through `ARG`, `ENV`, or Compose runtime env.
For custom Den API images, `DEN_UPLOAD_SENTRY_SOURCEMAPS=1` requires
`sentry_auth_token`, `sentry_org`, `sentry_project`, and `sentry_release`.
`sentry_url` and `sentry_dist` are optional:
```bash
SENTRY_AUTH_TOKEN=... SENTRY_ORG=... SENTRY_PROJECT=... \
SENTRY_RELEASE=2026.07.11 SENTRY_DIST=den-api \
docker buildx build \
-f packaging/docker/Dockerfile.den \
--build-arg DEN_UPLOAD_SENTRY_SOURCEMAPS=1 \
--secret id=sentry_auth_token,env=SENTRY_AUTH_TOKEN \
--secret id=sentry_org,env=SENTRY_ORG \
--secret id=sentry_project,env=SENTRY_PROJECT \
--secret id=sentry_release,env=SENTRY_RELEASE \
--secret id=sentry_dist,env=SENTRY_DIST \
-t openwork-den-api:sentry .
```
For custom Den Web images, `DEN_WEB_UPLOAD_SENTRY_SOURCEMAPS=1` requires
`sentry_auth_token`, `sentry_org`, and `sentry_project`. `sentry_url`,
`sentry_release`, and `sentry_dist` are optional. The Dockerfile exports the
upload flag and secrets only inside the build step; runtime Sentry still
requires its own runtime environment configuration:
```bash
SENTRY_AUTH_TOKEN=... SENTRY_ORG=... SENTRY_PROJECT=... \
SENTRY_RELEASE=2026.07.11 SENTRY_DIST=den-web SENTRY_URL=... \
docker buildx build \
-f packaging/docker/Dockerfile.den-web \
--build-arg DEN_WEB_UPLOAD_SENTRY_SOURCEMAPS=1 \
--secret id=sentry_auth_token,env=SENTRY_AUTH_TOKEN \
--secret id=sentry_org,env=SENTRY_ORG \
--secret id=sentry_project,env=SENTRY_PROJECT \
--secret id=sentry_url,env=SENTRY_URL \
--secret id=sentry_release,env=SENTRY_RELEASE \
--secret id=sentry_dist,env=SENTRY_DIST \
-t openwork-den-web:sentry .
```
The EE image publish workflow never passes repository Sentry secrets to
`pull_request` builds. On trusted publish events, it checks for the complete
configuration for each image and uses the Sentry upload build only for that
configured image; otherwise it publishes the normal image without failing the
release. Generic prebuilt images cannot perform source-map uploads for a
deployment-specific Sentry project after publication.
Caveats: `grafana/otel-lgtm` is intended for development, demos, and tests, not production retention or security. The default Grafana credentials are public, and the digest should be refreshed deliberately when updating the image tag. Upstream images are signed with cosign; see the `grafana/docker-otel-lgtm` README if you need signature verification in CI.
### Install links (self-host)
Run the install-link migration once against the Den database:
```bash
docker compose -f packaging/docker/docker-compose.den-dev.yml exec den sh -lc "node /app/ee/packages/den-db/dist/scripts/bootstrap.js"
```
Set `DEN_BOOTSTRAP_ADMIN_EMAILS` on the Den API service, restart it, open `/admin`, and toggle `Install links` for each org. Optional installer artifact env vars are `OPENWORK_INSTALLER_RELEASE_TAG`, `OPENWORK_INSTALLER_RELEASE_REPO`, and `OPENWORK_INSTALLER_ARTIFACTS_DIR`; see the [operator guide](../../docs/org-install-links.md).
### Faster inner-loop alternative
If you are iterating on Den locally and do not need the full Dockerized web stack, use the hybrid path instead:
From the OpenWork repo root:
```bash
pnpm dev:den
```
Or from the OpenWork enterprise root:
```bash
pnpm --dir _repos/openwork dev:den
```
What it does:
- Starts only **MySQL** in Docker
- Runs **Den controller** locally in watch mode
- Runs **OpenWork Cloud web app** locally in Next.js dev mode
- Reuses the existing local-dev wiring in `scripts/dev-web-local.sh`
This is usually the fastest path for UI/auth/control-plane iteration because it avoids rebuilding the Docker web image on each boot.
If you want to run the pieces in separate terminals, use the root package scripts:
```bash
pnpm dev:den:mysql
pnpm dev:den:db-push
pnpm dev:den:api
pnpm dev:den:web
```
The split API/web flow defaults to Den API on `http://localhost:8790` and Den web on `http://localhost:3005`. Stop the local MySQL container with:
```bash
pnpm dev:den:mysql:down
```
---
## Pre-baked Micro-Sandbox Image
For micro-sandbox work, use the pre-baked image that compiles `openwork-server` from source and downloads the pinned `opencode` binary during `docker build`.
Build it from the repo root:
```bash
./scripts/build-microsandbox-openwork-image.sh
```
Run it locally:
```bash
docker run --rm -p 8787:8787 \
-e OPENWORK_CONNECT_HOST=127.0.0.1 \
openwork-microsandbox:dev
```
Defaults:
- `OPENWORK_TOKEN=microsandbox-token`
- `OPENWORK_HOST_TOKEN=microsandbox-host-token`
- `OPENWORK_APPROVAL_MODE=auto`
Verification:
- Health: `curl http://127.0.0.1:8787/health`
- Authenticated API call: `curl -H "Authorization: Bearer microsandbox-token" http://127.0.0.1:8787/workspaces`
- Docker health: `docker inspect --format '{{json .State.Health}}' <container>`
Useful overrides:
- `OPENWORK_TOKEN` — set your own client bearer token
- `OPENWORK_HOST_TOKEN` — set your own host/admin token
- `OPENWORK_CONNECT_HOST` — host name embedded in the printed connect URL
- `DOCKER_PLATFORM` — optional platform passed to `docker build`
---
## Production container
This is a minimal packaging template to run the OpenWork Host contract in a single container.
It runs:
- `openwork-server` published on `0.0.0.0:8787` (the only published surface)
- Managed `opencode` launched internally by `openwork-server`
### Local run (compose)
From this directory:
```bash
docker compose up --build
```
Then open:
- `http://127.0.0.1:8787/health`
### Config
Recommended env vars:
- `OPENWORK_TOKEN` (client token)
- `OPENWORK_HOST_TOKEN` (host/owner token)
Optional:
- `OPENWORK_APPROVAL_MODE=auto|manual`
- `OPENWORK_APPROVAL_TIMEOUT_MS=30000`
Persistence:
- Workspace is mounted at `/workspace`
- Host data dir is mounted at `/data` (OpenCode caches + OpenWork server config/tokens)
### Notes
- OpenCode is not exposed directly; access it via the OpenWork proxy (`/opencode/*`).
- For PaaS, replace `./workspace:/workspace` with a volume or a checkout strategy (git clone on boot).
+301
View File
@@ -0,0 +1,301 @@
#!/usr/bin/env bash
set -euo pipefail
# Bring up a local Den testability stack with random host ports.
#
# Usage (from _repos/openwork repo root):
# packaging/docker/den-dev-up.sh
#
# Outputs:
# - OpenWork web app URL
# - Den control plane demo/API URL
# - Runtime env file path with ports + project name
#
# Notes:
# - This script auto-generates a Better Auth secret per run.
# - It also uses a premade dev-only DB encryption key unless you override
# DEN_DB_ENCRYPTION_KEY yourself.
# - Generate a replacement with: openssl rand -base64 128
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
COMPOSE_FILE="$ROOT_DIR/packaging/docker/docker-compose.den-dev.yml"
RUNTIME_DIR="$ROOT_DIR/tmp/docker-den-dev"
DAYTONA_ENV_FILE="${DAYTONA_ENV_FILE:-$ROOT_DIR/.env.daytona}"
if ! command -v docker >/dev/null 2>&1; then
echo "docker is required" >&2
exit 1
fi
pick_port() {
node -e "
const net = require('net');
const server = net.createServer();
server.listen(0, '127.0.0.1', () => {
const { port } = server.address();
console.log(port);
server.close();
});
"
}
random_hex() {
local bytes="$1"
node -e "console.log(require('crypto').randomBytes(${bytes}).toString('hex'))"
}
detect_lan_ipv4() {
node -e '
const os = require("os");
const nets = os.networkInterfaces();
for (const entries of Object.values(nets)) {
for (const entry of entries || []) {
if (!entry || entry.internal || entry.family !== "IPv4") continue;
if (entry.address.startsWith("127.")) continue;
process.stdout.write(entry.address);
process.exit(0);
}
}
'
}
detect_tailscale_dns_name() {
if ! command -v tailscale >/dev/null 2>&1; then
return 1
fi
tailscale status --json 2>/dev/null | node -e '
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { data += chunk; });
process.stdin.on("end", () => {
try {
const parsed = JSON.parse(data);
const value = (parsed?.Self?.DNSName || "").replace(/\.$/, "").trim();
if (!value) process.exit(1);
process.stdout.write(value);
} catch {
process.exit(1);
}
});
'
}
detect_public_host() {
if [ -n "${DEN_PUBLIC_HOST:-}" ]; then
printf '%s\n' "$DEN_PUBLIC_HOST"
return
fi
local lan_ipv4
lan_ipv4="$(detect_lan_ipv4 || true)"
if [ -n "$lan_ipv4" ]; then
printf '%s\n' "$lan_ipv4"
return
fi
local host
host="$(hostname -s 2>/dev/null || hostname 2>/dev/null || true)"
host="${host//$'\n'/}"
host="${host// /}"
if [ -n "$host" ]; then
printf '%s\n' "$host"
return
fi
printf '%s\n' "localhost"
}
append_origin() {
local value="$1"
[ -n "$value" ] || return 0
if [ -z "${DEN_CORS_ORIGINS:-}" ]; then
DEN_CORS_ORIGINS="$value"
return 0
fi
case ",${DEN_CORS_ORIGINS}," in
*",${value},"*) ;;
*) DEN_CORS_ORIGINS="${DEN_CORS_ORIGINS},${value}" ;;
esac
}
DEV_ID="$(node -e "console.log(require('crypto').randomUUID().slice(0, 8))")"
PROJECT="openwork-den-dev-$DEV_ID"
DEN_WATCH_OTP_CODES="${DEN_WATCH_OTP_CODES:-1}"
DEN_API_PORT="${DEN_API_PORT:-$(pick_port)}"
DEN_WEB_PORT="${DEN_WEB_PORT:-$(pick_port)}"
DEN_MYSQL_PORT="${DEN_MYSQL_PORT:-$(pick_port)}"
if [ "$DEN_WEB_PORT" = "$DEN_API_PORT" ]; then
DEN_WEB_PORT="$(pick_port)"
fi
if [ "$DEN_MYSQL_PORT" = "$DEN_API_PORT" ] || [ "$DEN_MYSQL_PORT" = "$DEN_WEB_PORT" ]; then
DEN_MYSQL_PORT="$(pick_port)"
fi
PUBLIC_HOST="$(detect_public_host)"
LAN_IPV4="$(detect_lan_ipv4 || true)"
TAILSCALE_DNS_NAME="$(detect_tailscale_dns_name || true)"
DEN_BETTER_AUTH_SECRET="${DEN_BETTER_AUTH_SECRET:-$(random_hex 32)}"
DEN_DB_ENCRYPTION_KEY="${DEN_DB_ENCRYPTION_KEY:-dev-den-db-encryption-key-please-change-1234567890}"
DEN_BETTER_AUTH_URL="${DEN_BETTER_AUTH_URL:-http://$PUBLIC_HOST:$DEN_WEB_PORT}"
DEN_ORG_MODE="${DEN_ORG_MODE:-single_org}"
DEN_SINGLE_ORG_NAME="${DEN_SINGLE_ORG_NAME:-OpenWork}"
DEN_SINGLE_ORG_SLUG="${DEN_SINGLE_ORG_SLUG:-default}"
DEN_SINGLE_ORG_OWNER_EMAILS="${DEN_SINGLE_ORG_OWNER_EMAILS:-}"
DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP="${DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP:-false}"
DEN_SINGLE_ORG_ALLOW_EMAIL_PASSWORD_AFTER_SSO="${DEN_SINGLE_ORG_ALLOW_EMAIL_PASSWORD_AFTER_SSO:-false}"
DEN_REQUIRE_EMAIL_VERIFICATION="${DEN_REQUIRE_EMAIL_VERIFICATION:-false}"
DEN_PROVISIONER_MODE="${DEN_PROVISIONER_MODE:-stub}"
DEN_WORKER_URL_TEMPLATE="${DEN_WORKER_URL_TEMPLATE:-https://workers.local/{workerId}}"
DEN_CORS_ORIGINS="${DEN_CORS_ORIGINS:-http://localhost:$DEN_WEB_PORT,http://127.0.0.1:$DEN_WEB_PORT,http://0.0.0.0:$DEN_WEB_PORT,http://localhost:$DEN_API_PORT,http://127.0.0.1:$DEN_API_PORT}"
append_origin "http://$PUBLIC_HOST:$DEN_WEB_PORT"
append_origin "http://$PUBLIC_HOST:$DEN_API_PORT"
if [ -n "$LAN_IPV4" ]; then
append_origin "http://$LAN_IPV4:$DEN_WEB_PORT"
append_origin "http://$LAN_IPV4:$DEN_API_PORT"
fi
DEN_BETTER_AUTH_TRUSTED_ORIGINS="${DEN_BETTER_AUTH_TRUSTED_ORIGINS:-$DEN_CORS_ORIGINS}"
if [ "$DEN_PROVISIONER_MODE" = "daytona" ] && [ -f "$DAYTONA_ENV_FILE" ]; then
set -a
# shellcheck disable=SC1090
source "$DAYTONA_ENV_FILE"
set +a
fi
if [ "$DEN_PROVISIONER_MODE" = "daytona" ] && [ -z "${DAYTONA_API_KEY:-}" ]; then
echo "DAYTONA_API_KEY is required when DEN_PROVISIONER_MODE=daytona" >&2
echo "Set DAYTONA_ENV_FILE to your .env.daytona path or export DAYTONA_API_KEY before running den-dev-up.sh" >&2
exit 1
fi
mkdir -p "$RUNTIME_DIR"
RUNTIME_FILE="$ROOT_DIR/tmp/.den-dev-env-$DEV_ID"
start_otp_log_watch() {
if [ "$DEN_WATCH_OTP_CODES" != "1" ]; then
return
fi
(
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs -f --since=1s den 2>&1 | while IFS= read -r line; do
case "$line" in
*"[auth] dev verification code for "*)
if [[ "$line" == *" | "* ]]; then
line="${line#* | }"
fi
printf '\n[den otp] %s\n' "$line" >&2
;;
esac
done
) &
OTP_LOG_PID=$!
}
cat > "$RUNTIME_FILE" <<EOF
PROJECT=$PROJECT
DEN_API_PORT=$DEN_API_PORT
DEN_WEB_PORT=$DEN_WEB_PORT
DEN_MYSQL_PORT=$DEN_MYSQL_PORT
DEN_API_URL=http://localhost:$DEN_API_PORT
DEN_WEB_URL=http://localhost:$DEN_WEB_PORT
DEN_API_PUBLIC_URL=http://$PUBLIC_HOST:$DEN_API_PORT
DEN_WEB_PUBLIC_URL=http://$PUBLIC_HOST:$DEN_WEB_PORT
DEN_MYSQL_URL=mysql://root:password@127.0.0.1:$DEN_MYSQL_PORT/openwork_den
DEN_BETTER_AUTH_URL=$DEN_BETTER_AUTH_URL
DEN_DB_ENCRYPTION_KEY=$DEN_DB_ENCRYPTION_KEY
DEN_ORG_MODE=$DEN_ORG_MODE
DEN_SINGLE_ORG_NAME=$DEN_SINGLE_ORG_NAME
DEN_SINGLE_ORG_SLUG=$DEN_SINGLE_ORG_SLUG
DEN_SINGLE_ORG_OWNER_EMAILS=$DEN_SINGLE_ORG_OWNER_EMAILS
DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP=$DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP
DEN_SINGLE_ORG_ALLOW_EMAIL_PASSWORD_AFTER_SSO=$DEN_SINGLE_ORG_ALLOW_EMAIL_PASSWORD_AFTER_SSO
DEN_REQUIRE_EMAIL_VERIFICATION=$DEN_REQUIRE_EMAIL_VERIFICATION
COMPOSE_FILE=$COMPOSE_FILE
DEN_WATCH_OTP_CODES=$DEN_WATCH_OTP_CODES
EOF
echo "Starting Docker Compose project: $PROJECT" >&2
echo "- DEN_API_PORT=$DEN_API_PORT" >&2
echo "- DEN_WEB_PORT=$DEN_WEB_PORT" >&2
echo "- DEN_MYSQL_PORT=$DEN_MYSQL_PORT" >&2
echo "- DEN_BETTER_AUTH_URL=$DEN_BETTER_AUTH_URL" >&2
echo "- DEN_DB_ENCRYPTION_KEY=[set] (dev default unless overridden)" >&2
echo "- DEN_ORG_MODE=$DEN_ORG_MODE" >&2
echo "- DEN_SINGLE_ORG_SLUG=$DEN_SINGLE_ORG_SLUG" >&2
echo "- DEN_PROVISIONER_MODE=$DEN_PROVISIONER_MODE" >&2
echo "- OTP verification codes will stream back to this terminal" >&2
if [ "$DEN_PROVISIONER_MODE" = "daytona" ]; then
echo "- DAYTONA_API_URL=${DAYTONA_API_URL:-https://app.daytona.io/api}" >&2
if [ -n "${DAYTONA_TARGET:-}" ]; then
echo "- DAYTONA_TARGET=$DAYTONA_TARGET" >&2
fi
fi
if ! DEN_API_PORT="$DEN_API_PORT" \
DEN_WEB_PORT="$DEN_WEB_PORT" \
DEN_MYSQL_PORT="$DEN_MYSQL_PORT" \
DEN_BETTER_AUTH_SECRET="$DEN_BETTER_AUTH_SECRET" \
DEN_DB_ENCRYPTION_KEY="$DEN_DB_ENCRYPTION_KEY" \
DEN_BETTER_AUTH_URL="$DEN_BETTER_AUTH_URL" \
DEN_ORG_MODE="$DEN_ORG_MODE" \
DEN_SINGLE_ORG_NAME="$DEN_SINGLE_ORG_NAME" \
DEN_SINGLE_ORG_SLUG="$DEN_SINGLE_ORG_SLUG" \
DEN_SINGLE_ORG_OWNER_EMAILS="$DEN_SINGLE_ORG_OWNER_EMAILS" \
DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP="$DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP" \
DEN_SINGLE_ORG_ALLOW_EMAIL_PASSWORD_AFTER_SSO="$DEN_SINGLE_ORG_ALLOW_EMAIL_PASSWORD_AFTER_SSO" \
DEN_REQUIRE_EMAIL_VERIFICATION="$DEN_REQUIRE_EMAIL_VERIFICATION" \
DEN_CORS_ORIGINS="$DEN_CORS_ORIGINS" \
DEN_BETTER_AUTH_TRUSTED_ORIGINS="$DEN_BETTER_AUTH_TRUSTED_ORIGINS" \
DEN_PROVISIONER_MODE="$DEN_PROVISIONER_MODE" \
DEN_WORKER_URL_TEMPLATE="$DEN_WORKER_URL_TEMPLATE" \
DAYTONA_API_URL="${DAYTONA_API_URL:-}" \
DAYTONA_API_KEY="${DAYTONA_API_KEY:-}" \
DAYTONA_TARGET="${DAYTONA_TARGET:-}" \
DAYTONA_SNAPSHOT="${DAYTONA_SNAPSHOT:-}" \
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" up -d --build --wait; then
echo "Den Docker stack failed to start. Recent logs:" >&2
docker compose -p "$PROJECT" -f "$COMPOSE_FILE" logs --tail=200 >&2 || true
exit 1
fi
OTP_LOG_PID=""
start_otp_log_watch
if [ -n "$OTP_LOG_PID" ]; then
printf 'OTP_LOG_PID=%s\n' "$OTP_LOG_PID" >> "$RUNTIME_FILE"
fi
echo "" >&2
echo "OpenWork web UI: http://localhost:$DEN_WEB_PORT" >&2
echo "OpenWork web UI (LAN/public): http://$PUBLIC_HOST:$DEN_WEB_PORT" >&2
if [ -n "$LAN_IPV4" ]; then
echo "OpenWork web UI (LAN IP): http://$LAN_IPV4:$DEN_WEB_PORT" >&2
fi
if [ -n "$TAILSCALE_DNS_NAME" ]; then
echo "OpenWork web UI (Tailscale): http://$TAILSCALE_DNS_NAME:$DEN_WEB_PORT" >&2
fi
echo "Den demo/API: http://localhost:$DEN_API_PORT" >&2
echo "Den demo/API (LAN/public): http://$PUBLIC_HOST:$DEN_API_PORT" >&2
if [ -n "$LAN_IPV4" ]; then
echo "Den demo/API (LAN IP): http://$LAN_IPV4:$DEN_API_PORT" >&2
fi
if [ -n "$TAILSCALE_DNS_NAME" ]; then
echo "Den demo/API (Tailscale): http://$TAILSCALE_DNS_NAME:$DEN_API_PORT" >&2
fi
echo "MySQL: mysql://root:password@127.0.0.1:$DEN_MYSQL_PORT/openwork_den" >&2
echo "Health check: http://localhost:$DEN_API_PORT/health" >&2
echo "Runtime env file: $RUNTIME_FILE" >&2
if [ -n "$OTP_LOG_PID" ]; then
echo "OTP watch: active in this terminal (PID $OTP_LOG_PID)" >&2
echo "Stop OTP watch only: kill $OTP_LOG_PID" >&2
echo " export DEN_WATCH_OTP_CODES=0 to disable" >&2
fi
echo "" >&2
echo "To stop this stack (keep DB data):" >&2
echo " docker compose -p $PROJECT -f $COMPOSE_FILE down" >&2
echo "To stop and reset the DB:" >&2
echo " docker compose -p $PROJECT -f $COMPOSE_FILE down -v" >&2
+193
View File
@@ -0,0 +1,193 @@
# docker-compose.den-dev.yml — Den local testability stack
#
# Usage (from repo root):
# docker compose -f packaging/docker/docker-compose.den-dev.yml up
#
# Then open the printed web UI or Den demo URL.
#
# Env overrides (optional, via export or .env):
# DEN_API_PORT — host port to map to Den control plane :8788
# DEN_WEB_PORT — host port to map to the cloud web app :3005
# DEN_MYSQL_PORT — host port to map to MySQL :3306
# DEN_BETTER_AUTH_SECRET — Better Auth secret (auto-generated by den-dev-up.sh)
# DEN_DB_ENCRYPTION_KEY — dev-only DB encryption key for encrypted columns
# — defaults to a premade local key for Docker smoke tests
# — generate a replacement with: openssl rand -base64 128
# DEN_PUBLIC_HOST — browser-facing host/IP for LAN access (set by den-dev-up.sh)
# DEN_BETTER_AUTH_URL — browser-facing auth origin (default: http://<DEN_PUBLIC_HOST>:<DEN_WEB_PORT>)
# DEN_BETTER_AUTH_TRUSTED_ORIGINS — Better Auth trusted origins (defaults to DEN_CORS_ORIGINS)
# DEN_MCP_ADDITIONAL_RESOURCES — Extra public MCP resource URLs this deployment serves, e.g. https://api.openworklabs.com/mcp — needed when den-api is reachable on more than one public origin
# DEN_CORS_ORIGINS — comma-separated trusted origins for Better Auth + CORS
# DEN_ORG_MODE — single_org or multi_org (default: single_org)
# DEN_SINGLE_ORG_NAME / DEN_SINGLE_ORG_SLUG / DEN_SINGLE_ORG_OWNER_EMAILS
# — singleton organization defaults for local private-mode testing
# DEN_INITIAL_ADMIN_BOOTSTRAP_CODE / DEN_INITIAL_ADMIN_BOOTSTRAP_CODE_FILE
# — optional one-time /setup code for private zero-user deployments
# DEN_PROVISIONER_MODE — stub, render, or daytona (default: stub)
# DEN_WORKER_URL_TEMPLATE — worker URL template used by stub provisioning
# DAYTONA_API_URL / DAYTONA_API_KEY / DAYTONA_TARGET / DAYTONA_SNAPSHOT
# — optional Daytona passthrough vars when DEN_PROVISIONER_MODE=daytona
# POLAR_FEATURE_GATE_ENABLED / POLAR_API_BASE / POLAR_ACCESS_TOKEN
# POLAR_PRODUCT_ID / POLAR_BENEFIT_ID / POLAR_SUCCESS_URL / POLAR_RETURN_URL
# — optional Polar passthrough vars for billing/paywall testing
# DEN_OBSERVABILITY_BACKEND — none, otel, or sentry (default: none)
# OTEL_EXPORTER_OTLP_ENDPOINT — OTLP/HTTP base endpoint for den + web, e.g. http://otel-lgtm:4318
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT / OTEL_EXPORTER_OTLP_METRICS_ENDPOINT / OTEL_EXPORTER_OTLP_LOGS_ENDPOINT
# — optional per-signal OTLP/HTTP endpoint overrides
# OTEL_EXPORTER_OTLP_HEADERS — optional OTLP header string, e.g. Authorization=Bearer token
# OTEL_EXPORTER_OTLP_PROTOCOL — only http/protobuf is supported (default: http/protobuf)
# OTEL_EXPORTER_OTLP_TRACES_PROTOCOL / OTEL_EXPORTER_OTLP_METRICS_PROTOCOL / OTEL_EXPORTER_OTLP_LOGS_PROTOCOL
# — optional per-signal protocol overrides, only http/protobuf is supported
# OTEL_TRACES_EXPORTER / OTEL_METRICS_EXPORTER / OTEL_LOGS_EXPORTER
# — otlp or none per signal (default: otlp)
# OTEL_TRACES_SAMPLER / OTEL_TRACES_SAMPLER_ARG
# — standard OpenTelemetry sampler controls
# SENTRY_DSN / SENTRY_TRACES_SAMPLE_RATE / SENTRY_ENVIRONMENT / SENTRY_RELEASE / SENTRY_DIST
# — optional Sentry runtime settings (source-map upload is build-time only)
# DEN_AUTOMATIONS_POLL_INTERVAL_MS and friends
# — tune Den's always-on in-process Automation loop
x-shared: &shared
restart: unless-stopped
x-observability-env: &observability-env
DEN_OBSERVABILITY_BACKEND: ${DEN_OBSERVABILITY_BACKEND:-none}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: ${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-}
OTEL_EXPORTER_OTLP_METRICS_ENDPOINT: ${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:-}
OTEL_EXPORTER_OTLP_LOGS_ENDPOINT: ${OTEL_EXPORTER_OTLP_LOGS_ENDPOINT:-}
OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS:-}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf}
OTEL_EXPORTER_OTLP_TRACES_PROTOCOL: ${OTEL_EXPORTER_OTLP_TRACES_PROTOCOL:-}
OTEL_EXPORTER_OTLP_METRICS_PROTOCOL: ${OTEL_EXPORTER_OTLP_METRICS_PROTOCOL:-}
OTEL_EXPORTER_OTLP_LOGS_PROTOCOL: ${OTEL_EXPORTER_OTLP_LOGS_PROTOCOL:-}
OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER:-otlp}
OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER:-otlp}
OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER:-otlp}
OTEL_TRACES_SAMPLER: ${OTEL_TRACES_SAMPLER:-parentbased_always_on}
OTEL_TRACES_SAMPLER_ARG: ${OTEL_TRACES_SAMPLER_ARG:-}
SENTRY_DSN: ${SENTRY_DSN:-}
SENTRY_TRACES_SAMPLE_RATE: ${SENTRY_TRACES_SAMPLE_RATE:-0.01}
SENTRY_LOG_LEVEL: ${SENTRY_LOG_LEVEL:-warn}
SENTRY_ENVIRONMENT: ${SENTRY_ENVIRONMENT:-}
SENTRY_RELEASE: ${SENTRY_RELEASE:-}
SENTRY_DIST: ${SENTRY_DIST:-}
services:
mysql:
image: mysql:8.4
command:
- --performance_schema=OFF
- --innodb-buffer-pool-size=64M
- --innodb-log-buffer-size=8M
- --tmp-table-size=16M
- --max-heap-table-size=16M
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: openwork_den
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -ppassword --silent"]
interval: 5s
timeout: 5s
retries: 30
start_period: 10s
ports:
- "${DEN_MYSQL_PORT:-3306}:3306"
volumes:
- den-mysql-data:/var/lib/mysql
den:
<<: *shared
build:
context: ../../
dockerfile: packaging/docker/Dockerfile.den
command: ["sh", "-lc", "cd /app/ee/packages/den-db && yes | node --import tsx ./node_modules/drizzle-kit/bin.cjs push --config drizzle.config.ts && node /app/ee/apps/den-api/dist/main.js"]
depends_on:
mysql:
condition: service_healthy
ports:
- "${DEN_API_PORT:-8788}:8788"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8788/health').then((res)=>process.exit(res.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 5s
retries: 30
start_period: 120s
environment:
<<: *observability-env
CI: "true"
OPENWORK_DEV_MODE: ${OPENWORK_DEV_MODE:-1}
OTEL_SERVICE_NAME: ${DEN_API_OTEL_SERVICE_NAME:-den-api}
DATABASE_URL: mysql://root:password@mysql:3306/openwork_den
BETTER_AUTH_SECRET: ${DEN_BETTER_AUTH_SECRET:-dev-den-local-auth-secret-please-override-1234567890}
DEN_DB_ENCRYPTION_KEY: ${DEN_DB_ENCRYPTION_KEY:-dev-den-db-encryption-key-please-change-1234567890}
BETTER_AUTH_URL: ${DEN_BETTER_AUTH_URL:-http://localhost:3005}
DEN_ORG_MODE: ${DEN_ORG_MODE:-single_org}
DEN_SINGLE_ORG_NAME: ${DEN_SINGLE_ORG_NAME:-OpenWork}
DEN_SINGLE_ORG_SLUG: ${DEN_SINGLE_ORG_SLUG:-default}
DEN_SINGLE_ORG_OWNER_EMAILS: ${DEN_SINGLE_ORG_OWNER_EMAILS:-}
DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP: ${DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP:-false}
DEN_INITIAL_ADMIN_BOOTSTRAP_CODE: ${DEN_INITIAL_ADMIN_BOOTSTRAP_CODE:-}
DEN_INITIAL_ADMIN_BOOTSTRAP_CODE_FILE: ${DEN_INITIAL_ADMIN_BOOTSTRAP_CODE_FILE:-}
DEN_SINGLE_ORG_ALLOW_EMAIL_PASSWORD_AFTER_SSO: ${DEN_SINGLE_ORG_ALLOW_EMAIL_PASSWORD_AFTER_SSO:-false}
DEN_REQUIRE_EMAIL_VERIFICATION: ${DEN_REQUIRE_EMAIL_VERIFICATION:-false}
DEN_MCP_RESOURCE_URL: ${DEN_MCP_RESOURCE_URL:-http://localhost:8788/mcp}
DEN_MCP_ADDITIONAL_RESOURCES: ${DEN_MCP_ADDITIONAL_RESOURCES:-}
DEN_API_PUBLIC_URL: ${DEN_API_PUBLIC_URL:-http://localhost:8788}
DEN_BETTER_AUTH_TRUSTED_ORIGINS: ${DEN_BETTER_AUTH_TRUSTED_ORIGINS:-}
PORT: "8788"
CORS_ORIGINS: ${DEN_CORS_ORIGINS:-http://localhost:3005,http://127.0.0.1:3005,http://0.0.0.0:3005,http://localhost:5173,http://127.0.0.1:5173,http://localhost:8788,http://127.0.0.1:8788}
PROVISIONER_MODE: ${DEN_PROVISIONER_MODE:-stub}
WORKER_URL_TEMPLATE: ${DEN_WORKER_URL_TEMPLATE:-}
POLAR_FEATURE_GATE_ENABLED: ${POLAR_FEATURE_GATE_ENABLED:-false}
POLAR_API_BASE: ${POLAR_API_BASE:-}
POLAR_ACCESS_TOKEN: ${POLAR_ACCESS_TOKEN:-}
POLAR_PRODUCT_ID: ${POLAR_PRODUCT_ID:-}
POLAR_BENEFIT_ID: ${POLAR_BENEFIT_ID:-}
POLAR_SUCCESS_URL: ${POLAR_SUCCESS_URL:-}
POLAR_RETURN_URL: ${POLAR_RETURN_URL:-}
DAYTONA_API_URL: ${DAYTONA_API_URL:-}
DAYTONA_API_KEY: ${DAYTONA_API_KEY:-}
DAYTONA_TARGET: ${DAYTONA_TARGET:-}
DAYTONA_SNAPSHOT: ${DAYTONA_SNAPSHOT:-}
DEN_AUTOMATIONS_POLL_INTERVAL_MS: ${DEN_AUTOMATIONS_POLL_INTERVAL_MS:-15000}
DEN_AUTOMATIONS_BATCH_SIZE: ${DEN_AUTOMATIONS_BATCH_SIZE:-25}
DEN_AUTOMATIONS_MAX_CONCURRENCY: ${DEN_AUTOMATIONS_MAX_CONCURRENCY:-4}
DEN_AUTOMATIONS_LEASE_MS: ${DEN_AUTOMATIONS_LEASE_MS:-60000}
DEN_AUTOMATIONS_RUN_TIMEOUT_MS: ${DEN_AUTOMATIONS_RUN_TIMEOUT_MS:-900000}
DEN_AUTOMATIONS_RUNNER_CLAIM_DEADLINE_MS: ${DEN_AUTOMATIONS_RUNNER_CLAIM_DEADLINE_MS:-60000}
web:
<<: *shared
build:
context: ../../
dockerfile: packaging/docker/Dockerfile.den-web
depends_on:
den:
condition: service_healthy
ports:
- "${DEN_WEB_PORT:-3005}:3005"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3005/api/health').then((res)=>process.exit(res.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 10s
retries: 30
start_period: 180s
environment:
<<: *observability-env
CI: "true"
OPENWORK_DEV_MODE: "1"
OTEL_SERVICE_NAME: ${DEN_WEB_OTEL_SERVICE_NAME:-den-web}
DEN_API_BASE: http://den:8788
DEN_AUTH_FALLBACK_BASE: http://den:8788
DEN_AUTH_ORIGIN: ${DEN_BETTER_AUTH_URL:-http://localhost:3005}
DEN_WEB_PUBLIC_ORIGIN: ${DEN_BETTER_AUTH_URL:-http://localhost:3005}
DEN_WEB_OPENWORK_AUTH_CALLBACK_URL: ${DEN_BETTER_AUTH_URL:-http://localhost:3005}
DEN_ORG_MODE: ${DEN_ORG_MODE:-single_org}
DEN_SINGLE_ORG_NAME: ${DEN_SINGLE_ORG_NAME:-OpenWork}
DEN_SINGLE_ORG_SLUG: ${DEN_SINGLE_ORG_SLUG:-default}
DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP: ${DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP:-false}
DEN_SINGLE_ORG_ALLOW_EMAIL_PASSWORD_AFTER_SSO: ${DEN_SINGLE_ORG_ALLOW_EMAIL_PASSWORD_AFTER_SSO:-false}
volumes:
den-mysql-data:
+184
View File
@@ -0,0 +1,184 @@
# docker-compose.dev.yml — Dev testability stack (no custom Dockerfile)
#
# Usage (from repo root):
# docker compose -f packaging/docker/docker-compose.dev.yml up
#
# Then open the printed Web UI URL — already wired to headless, no config needed.
#
# Env overrides (optional, via .env or export):
# OPENWORK_TOKEN — shared client token (auto-generated if unset)
# OPENWORK_HOST_TOKEN — host/admin token (auto-generated if unset)
# OPENWORK_WORKSPACE — host path to mount as workspace (default: ./workspace)
# OPENWORK_PORT — host port to map to container :8787 (default: 8787)
# WEB_PORT — host port to map to container :5173 (default: 5173)
# OPENWORK_DEV_ID — unique ID for this stack (default: default)
# OPENWORK_DEV_MODE — enables isolated OpenCode dev state
# OPENWORK_PUBLIC_HOST — browser-facing host/IP for LAN-accessible URLs
# OPENWORK_DOCKER_DEV_MOUNT_HOST_OPENCODE=1 — import host OpenCode config/auth into the isolated dev state
# OPENWORK_OPENCODE_CONFIG_DIR — host OpenCode config dir detection override
# OPENWORK_OPENCODE_DATA_DIR — host OpenCode data dir detection override
# OPENWORK_HOST_OPENCODE_CONFIG_DIR — import source for OpenCode config
# OPENWORK_HOST_OPENCODE_DATA_DIR — import source for OpenCode auth/data
x-shared: &shared
image: node:22-bookworm-slim
working_dir: /app
volumes:
# Mount the entire repo so both services share node_modules + source
- ../../:/app
- pnpm-store:/root/.local/share/pnpm/store
- bun-install:/root/.bun
- ${OPENWORK_WORKSPACE:-./workspace}:/workspace
- ${OPENWORK_HOST_OPENCODE_CONFIG_DIR:-./workspace/.openwork-host-opencode-config}:/persist/.config/opencode:ro
- ${OPENWORK_HOST_OPENCODE_DATA_DIR:-./workspace/.openwork-host-opencode-data}:/persist/.openwork-host-opencode-data:ro
services:
openwork-server:
<<: *shared
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
# --- Ensure workspace dir is writable ---
mkdir -p /workspace && touch /workspace/.keep 2>/dev/null || true
# --- Install system deps ---
apt-get update -qq && apt-get install -y -qq --no-install-recommends \
curl ca-certificates unzip git >/dev/null 2>&1
# --- Install bun (cached in named volume) ---
export BUN_INSTALL="/root/.bun"
export PATH="$$BUN_INSTALL/bin:$$PATH"
if ! command -v bun >/dev/null 2>&1; then
echo "[openwork-server] Installing bun..."
curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"
fi
# --- Enable pnpm via corepack ---
corepack enable && corepack prepare pnpm@10.27.0 --activate
# --- Install deps ---
echo "[openwork-server] Installing dependencies..."
pnpm install --no-frozen-lockfile --network-concurrency 1 --child-concurrency 1
# --- Build the server plugins and install the pinned OpenCode binary ---
pnpm --filter openwork-server build
OPENCODE_VERSION=$$(node -e 'const fs=require("fs"); const parsed=JSON.parse(fs.readFileSync("/app/constants.json", "utf8")); process.stdout.write(String(parsed.opencodeVersion || "").trim().replace(/^v/, ""));')
export OPENCODE_INSTALL_DIR="/root/.opencode/bin"
mkdir -p "$$OPENCODE_INSTALL_DIR"
if ! command -v opencode >/dev/null 2>&1; then
curl -fsSL https://opencode.ai/install | bash -s -- --version "$$OPENCODE_VERSION" --no-modify-path
fi
export PATH="$$OPENCODE_INSTALL_DIR:$$PATH"
export OPENWORK_MANAGE_OPENCODE=1
export OPENWORK_OPENCODE_BIN="$$(command -v opencode)"
export OPENWORK_EXTENSIONS_PLUGIN_DIR="/app/apps/server/dist/opencode-plugins"
# --- Resolve tokens ---
if [ -z "$$OPENWORK_TOKEN" ]; then
OPENWORK_TOKEN=$$(cat /proc/sys/kernel/random/uuid)
export OPENWORK_TOKEN
fi
if [ -z "$$OPENWORK_HOST_TOKEN" ]; then
OPENWORK_HOST_TOKEN=$$(cat /proc/sys/kernel/random/uuid)
export OPENWORK_HOST_TOKEN
fi
# Write tokens so the web service can source them
mkdir -p /app/tmp
printf 'OPENWORK_TOKEN=%s\nOPENWORK_HOST_TOKEN=%s\n' \
"$$OPENWORK_TOKEN" "$$OPENWORK_HOST_TOKEN" > "/app/tmp/.dev-env-${OPENWORK_DEV_ID:-default}"
echo ""
echo "============================================"
echo " OpenWork server"
echo " Server: http://localhost:${OPENWORK_PORT:-8787}"
echo " Health: http://localhost:${OPENWORK_PORT:-8787}/health"
echo " Token: $$OPENWORK_TOKEN"
echo " Host token: $$OPENWORK_HOST_TOKEN"
echo "============================================"
echo ""
exec pnpm --filter openwork-server exec bun dist/cli.js \
--workspace /workspace \
--host 0.0.0.0 \
--port 8787 \
--token "$$OPENWORK_TOKEN" \
--host-token "$$OPENWORK_HOST_TOKEN" \
--approval auto \
--cors "*" \
--verbose
ports:
- "${OPENWORK_PORT:-8787}:8787"
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8787/health || exit 1"]
interval: 5s
timeout: 5s
retries: 30
start_period: 90s
environment:
CI: "true"
OPENWORK_TOKEN: ${OPENWORK_TOKEN:-}
OPENWORK_HOST_TOKEN: ${OPENWORK_HOST_TOKEN:-}
OPENWORK_DEV_ID: ${OPENWORK_DEV_ID:-default}
OPENWORK_DEV_MODE: ${OPENWORK_DEV_MODE:-1}
OPENWORK_DEV_OPENCODE_IMPORT_CONFIG_DIR: /persist/.config/opencode
OPENWORK_DEV_OPENCODE_IMPORT_DATA_DIR: /persist/.openwork-host-opencode-data
web:
<<: *shared
depends_on:
openwork-server:
condition: service_healthy
entrypoint: ["/bin/sh", "-c"]
command:
- |
set -e
# --- Install system deps ---
apt-get update -qq && apt-get install -y -qq --no-install-recommends \
curl ca-certificates >/dev/null 2>&1
# --- Bun + pnpm ---
export BUN_INSTALL="/root/.bun"
export PATH="$$BUN_INSTALL/bin:$$PATH"
if ! command -v bun >/dev/null 2>&1; then
curl -fsSL https://bun.sh/install | bash -s "bun-v1.3.14"
fi
corepack enable && corepack prepare pnpm@10.27.0 --activate
# --- Read token written by openwork-server ---
if [ -f "/app/tmp/.dev-env-${OPENWORK_DEV_ID:-default}" ]; then
. "/app/tmp/.dev-env-${OPENWORK_DEV_ID:-default}"
export VITE_OPENWORK_TOKEN="$$OPENWORK_TOKEN"
fi
echo ""
echo "============================================"
echo " OpenWork web UI"
echo " URL: http://localhost:${WEB_PORT:-5173}"
echo " Token: $${VITE_OPENWORK_TOKEN:-<see openwork-server logs>}"
echo "============================================"
echo ""
export VITE_OPENWORK_URL="http://${OPENWORK_PUBLIC_HOST:-localhost}:${OPENWORK_PORT:-8787}"
export VITE_OPENWORK_PORT="${OPENWORK_PORT:-8787}"
export VITE_ALLOWED_HOSTS="all"
export HOST="0.0.0.0"
export PORT="5173"
exec pnpm --filter @openwork/app exec vite \
--host 0.0.0.0 \
--port 5173 \
--strictPort
ports:
- "${WEB_PORT:-5173}:5173"
environment:
OPENWORK_DEV_ID: ${OPENWORK_DEV_ID:-default}
volumes:
pnpm-store:
name: openwork-dev-pnpm-store
bun-install:
name: openwork-dev-bun-install
+119
View File
@@ -0,0 +1,119 @@
# OpenWork evaluation stack — pull-only, no repository clone or build:
#
# umask 077
# printf 'OPENWORK_AUTH_SECRET=%s\nOPENWORK_DB_ENCRYPTION_KEY=%s\n' \
# "$(openssl rand -hex 32)" "$(openssl rand -hex 32)" > .env
# docker compose -f docker-compose.eval.yml up -d --wait
#
# Then open http://localhost:3005 and sign up.
#
# No repo clone, no build, no launcher script. Uses the published GHCR images.
# NOT a production posture: dev-grade MySQL credentials, stub worker provisioner,
# no cloud sandboxes, no email delivery, public signup enabled by default.
#
# Required secrets:
# OPENWORK_AUTH_SECRET Better Auth secret (at least 32 random characters)
# OPENWORK_DB_ENCRYPTION_KEY Den DB encryption key (at least 32 random characters)
#
# Optional knobs:
# OPENWORK_WEB_PORT host port for the web app (default: 3005)
# OPENWORK_API_PORT host port for the Den API (default: 8788)
# OPENWORK_ALLOW_SIGNUP allow public signup for evaluation (default: true)
# OPENWORK_ORG_NAME single organization name (default: OpenWork Evaluation)
name: openwork-eval
services:
mysql:
image: mysql:8.4@sha256:b3b90af2a6552ae30c266fdb7d5dd55f3afb72404bb78d37fe8a23eb857fd3fb
command:
- --performance_schema=OFF
- --innodb-buffer-pool-size=128M
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: openwork_den
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -ppassword --silent"]
interval: 5s
timeout: 5s
retries: 30
start_period: 10s
volumes:
- openwork-eval-mysql:/var/lib/mysql
# intentionally no host port: MySQL stays internal to the compose network
den-migrate:
image: ghcr.io/different-ai/openwork-den-api:0.18.37@sha256:5ae1fab94ae2badd49ae74649cfb38ca679a7afc7e9ae8926c927553a34caabd
depends_on:
mysql:
condition: service_healthy
restart: "no"
entrypoint: ["sh", "-lc"]
command: ["cd /app/ee/packages/den-db && (node ./dist/scripts/bootstrap.js || node --import tsx ./dist/scripts/bootstrap.js)"]
environment:
DATABASE_URL: mysql://root:password@mysql:3306/openwork_den
DEN_DB_ENCRYPTION_KEY: ${OPENWORK_DB_ENCRYPTION_KEY:?Set OPENWORK_DB_ENCRYPTION_KEY to at least 32 random characters}
den:
image: ghcr.io/different-ai/openwork-den-api:0.18.37@sha256:5ae1fab94ae2badd49ae74649cfb38ca679a7afc7e9ae8926c927553a34caabd
restart: unless-stopped
depends_on:
mysql:
condition: service_healthy
den-migrate:
condition: service_completed_successfully
ports:
- "127.0.0.1:${OPENWORK_API_PORT:-8788}:8788"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8788/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 5s
retries: 30
start_period: 60s
environment:
PORT: "8788"
DATABASE_URL: mysql://root:password@mysql:3306/openwork_den
BETTER_AUTH_SECRET: ${OPENWORK_AUTH_SECRET:?Set OPENWORK_AUTH_SECRET to at least 32 random characters}
DEN_DB_ENCRYPTION_KEY: ${OPENWORK_DB_ENCRYPTION_KEY:?Set OPENWORK_DB_ENCRYPTION_KEY to at least 32 random characters}
BETTER_AUTH_URL: http://localhost:${OPENWORK_WEB_PORT:-3005}
DEN_ORG_MODE: single_org
DEN_SINGLE_ORG_NAME: ${OPENWORK_ORG_NAME:-OpenWork Evaluation}
DEN_SINGLE_ORG_SLUG: default
DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP: ${OPENWORK_ALLOW_SIGNUP:-true}
DEN_REQUIRE_EMAIL_VERIFICATION: "false"
DEN_API_PUBLIC_URL: http://localhost:${OPENWORK_API_PORT:-8788}
DEN_MCP_RESOURCE_URL: http://localhost:${OPENWORK_API_PORT:-8788}/mcp
CORS_ORIGINS: http://localhost:${OPENWORK_WEB_PORT:-3005},http://127.0.0.1:${OPENWORK_WEB_PORT:-3005},http://localhost:${OPENWORK_API_PORT:-8788},http://127.0.0.1:${OPENWORK_API_PORT:-8788}
DEN_BETTER_AUTH_TRUSTED_ORIGINS: http://localhost:${OPENWORK_WEB_PORT:-3005},http://127.0.0.1:${OPENWORK_WEB_PORT:-3005},http://localhost:${OPENWORK_API_PORT:-8788},http://127.0.0.1:${OPENWORK_API_PORT:-8788}
PROVISIONER_MODE: stub
WORKER_URL_TEMPLATE: https://workers.local/{workerId}
web:
image: ghcr.io/different-ai/openwork-den-web:0.18.37@sha256:9ca96fb73bc3512852409f1508876e418cd56f6d5187ff4e0c683f5b69a2dda3
restart: unless-stopped
depends_on:
den:
condition: service_healthy
ports:
- "127.0.0.1:${OPENWORK_WEB_PORT:-3005}:3005"
healthcheck:
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3005/api/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 5s
timeout: 10s
retries: 30
start_period: 60s
environment:
# Browser-reachable API origin (what runtime-config hands to clients).
DEN_API_BASE: http://localhost:${OPENWORK_API_PORT:-8788}
# Container-internal fallback for server-side calls.
DEN_AUTH_FALLBACK_BASE: http://den:8788
DEN_AUTH_ORIGIN: http://localhost:${OPENWORK_WEB_PORT:-3005}
DEN_WEB_PUBLIC_ORIGIN: http://localhost:${OPENWORK_WEB_PORT:-3005}
DEN_WEB_OPENWORK_AUTH_CALLBACK_URL: http://localhost:${OPENWORK_WEB_PORT:-3005}
DEN_ORG_MODE: single_org
DEN_SINGLE_ORG_NAME: ${OPENWORK_ORG_NAME:-OpenWork Evaluation}
DEN_SINGLE_ORG_SLUG: default
DEN_SINGLE_ORG_ALLOW_PUBLIC_SIGNUP: ${OPENWORK_ALLOW_SIGNUP:-true}
volumes:
openwork-eval-mysql:
@@ -0,0 +1,31 @@
# docker-compose.otel-lgtm.yml — Local OpenTelemetry validation backend
#
# Bundles an OTLP receiver, Grafana, Tempo, Loki, Prometheus, and Pyroscope for
# local development/testing only. Send OTLP/HTTP protobuf to http://127.0.0.1:4318.
services:
otel-lgtm:
image: docker.io/grafana/otel-lgtm:0.11.16@sha256:6fbacaa2db3851ecb4234f74e14d8a072298e15922a3af976fcc03ae834d9d73
restart: unless-stopped
ports:
- "${OTEL_LGTM_GRAFANA_PORT:-3000}:3000"
- "${OTEL_LGTM_OTLP_GRPC_PORT:-4317}:4317"
- "${OTEL_LGTM_OTLP_HTTP_PORT:-4318}:4318"
healthcheck:
test:
- CMD-SHELL
- >
curl -fsS http://localhost:3000/api/health >/dev/null &&
curl -fsS http://localhost:3100/ready >/dev/null &&
curl -fsS http://localhost:3200/ready >/dev/null &&
curl -fsS http://localhost:9090/-/ready >/dev/null &&
curl -fsS http://localhost:13133/ready >/dev/null
interval: 5s
timeout: 5s
retries: 30
start_period: 30s
volumes:
- otel-lgtm-data:/data
volumes:
otel-lgtm-data:
@@ -0,0 +1,32 @@
services:
mysql:
image: mysql:8.4
container_name: openwork-web-local-mysql
ports:
- "3306:3306"
environment:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: openwork_den
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -ppassword --silent"]
interval: 5s
timeout: 5s
retries: 30
start_period: 10s
volumes:
- openwork-web-local-mysql-data:/var/lib/mysql
redis:
image: redis:7.4-alpine@sha256:e7723ff73d963f5cc6d9c4643ea3d989527a402a319239054e9472a7fb9219a2
container_name: openwork-web-local-redis
ports:
- "127.0.0.1:6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 30
start_period: 5s
volumes:
openwork-web-local-mysql-data:
+26
View File
@@ -0,0 +1,26 @@
services:
openwork-host:
build:
context: .
dockerfile: Dockerfile
args:
# Keep this in sync with apps/server/package.json if you want a pinned release.
OPENWORK_SERVER_VERSION: 0.18.4
OPENCODE_VERSION: 1.17.11
ports:
- "8787:8787"
environment:
# Set these explicitly for stable sharing.
# OPENWORK_TOKEN: "..."
# OPENWORK_HOST_TOKEN: "..."
# Optional: OPENWORK_APPROVAL_MODE: "auto"
# Optional: OPENWORK_APPROVAL_TIMEOUT_MS: "30000"
# Historical path retained so existing ./data volumes keep working.
# Only a directory name; the orchestrator CLI itself is gone.
OPENWORK_DATA_DIR: /data/openwork-orchestrator
OPENWORK_SIDECAR_DIR: /data/sidecars
volumes:
# Mount an existing project/workspace here.
- ./workspace:/workspace
# Persistent host data (OpenCode caches, server config, tokens).
- ./data:/data
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env sh
set -eu
OPENWORK_WORKSPACE="${OPENWORK_WORKSPACE:-/workspace}"
OPENWORK_DATA_DIR="${OPENWORK_DATA_DIR:-/data/openwork-server}"
OPENWORK_SIDECAR_DIR="${OPENWORK_SIDECAR_DIR:-/data/sidecars}"
OPENWORK_PORT="${OPENWORK_PORT:-8787}"
OPENWORK_TOKEN="${OPENWORK_TOKEN:-microsandbox-token}"
OPENWORK_HOST_TOKEN="${OPENWORK_HOST_TOKEN:-microsandbox-host-token}"
OPENWORK_APPROVAL_MODE="${OPENWORK_APPROVAL_MODE:-auto}"
OPENWORK_CORS_ORIGINS="${OPENWORK_CORS_ORIGINS:-*}"
OPENWORK_CONNECT_HOST="${OPENWORK_CONNECT_HOST:-127.0.0.1}"
OPENWORK_EXTENSIONS_PLUGIN_DIR="${OPENWORK_EXTENSIONS_PLUGIN_DIR:-/opt/openwork/opencode-plugins}"
HOME="${HOME:-/root}"
USER="${USER:-root}"
SHELL="${SHELL:-/bin/sh}"
XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
XDG_STATE_HOME="${XDG_STATE_HOME:-$HOME/.local/state}"
if [ "$HOME" = "/" ]; then
HOME=/root
XDG_CONFIG_HOME="$HOME/.config"
XDG_CACHE_HOME="$HOME/.cache"
XDG_DATA_HOME="$HOME/.local/share"
XDG_STATE_HOME="$HOME/.local/state"
fi
export HOME USER SHELL XDG_CONFIG_HOME XDG_CACHE_HOME XDG_DATA_HOME XDG_STATE_HOME
export OPENWORK_DATA_DIR OPENWORK_TOKEN OPENWORK_HOST_TOKEN OPENWORK_EXTENSIONS_PLUGIN_DIR
export OPENWORK_MANAGE_OPENCODE=1
export OPENWORK_OPENCODE_BIN=/usr/local/bin/opencode
mkdir -p "$OPENWORK_WORKSPACE" "$OPENWORK_DATA_DIR" "$OPENWORK_SIDECAR_DIR"
mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_CACHE_HOME" "$XDG_DATA_HOME" "$XDG_STATE_HOME"
printf '%s\n' "Starting OpenWork micro-sandbox"
printf '%s\n' "- workspace: $OPENWORK_WORKSPACE"
printf '%s\n' "- home: $HOME"
printf '%s\n' "- openwork url: http://$OPENWORK_CONNECT_HOST:$OPENWORK_PORT"
printf '%s\n' "- client token: $OPENWORK_TOKEN"
printf '%s\n' "- host token: $OPENWORK_HOST_TOKEN"
printf '%s\n' "- health: curl http://$OPENWORK_CONNECT_HOST:$OPENWORK_PORT/health"
printf '%s\n' "- auth test: curl -H \"Authorization: Bearer $OPENWORK_TOKEN\" http://$OPENWORK_CONNECT_HOST:$OPENWORK_PORT/workspaces"
exec openwork-server \
--workspace "$OPENWORK_WORKSPACE" \
--host 0.0.0.0 \
--port "$OPENWORK_PORT" \
--token "$OPENWORK_TOKEN" \
--host-token "$OPENWORK_HOST_TOKEN" \
--approval "$OPENWORK_APPROVAL_MODE" \
--cors "$OPENWORK_CORS_ORIGINS" \
--verbose
+778
View File
@@ -0,0 +1,778 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { randomBytes } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import { dirname } from "node:path";
function argValue(name, fallback) {
const index = process.argv.indexOf(`--${name}`);
if (index === -1) return fallback;
const value = process.argv[index + 1];
return value === undefined || value.startsWith("--") ? fallback : value;
}
function requiredArg(name) {
const value = argValue(name, "");
if (!value) throw new Error(`Missing --${name}`);
return value;
}
function trimTrailingSlash(value) {
return value.replace(/\/+$/, "");
}
function assertHex(value, size, label) {
if (!new RegExp(`^[0-9a-f]{${size}}$`).test(value)) {
throw new Error(`${label} must be ${size} lowercase hex characters`);
}
}
function nowIso() {
return new Date().toISOString();
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch {
return undefined;
}
}
function isObject(value) {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function normalizeValue(value) {
if (!isObject(value)) return value;
if ("stringValue" in value) return value.stringValue;
if ("intValue" in value) return Number(value.intValue);
if ("doubleValue" in value) return Number(value.doubleValue);
if ("boolValue" in value) return Boolean(value.boolValue);
if ("bytesValue" in value) return value.bytesValue;
if (isObject(value.arrayValue)) {
const values = Array.isArray(value.arrayValue.values) ? value.arrayValue.values : [];
return values.map(normalizeValue);
}
if (isObject(value.kvlistValue)) {
return attributesToMap(value.kvlistValue.values);
}
if ("value" in value && Object.keys(value).length <= 3) return normalizeValue(value.value);
return value;
}
function attributesToMap(attributes) {
const out = {};
if (Array.isArray(attributes)) {
for (const item of attributes) {
if (!isObject(item) || typeof item.key !== "string") continue;
out[item.key] = normalizeValue("value" in item ? item.value : item);
}
return out;
}
if (isObject(attributes)) {
for (const [key, value] of Object.entries(attributes)) out[key] = normalizeValue(value);
}
return out;
}
function serviceName(attrs) {
const value = attrs["service.name"] ?? attrs.service_name ?? attrs.serviceName;
return typeof value === "string" ? value : undefined;
}
function spanId(span) {
const value = span.spanId ?? span.spanID ?? span.span_id;
if (typeof value !== "string") return undefined;
if (/^[0-9a-f]{16}$/iu.test(value)) return value.toLowerCase();
return Buffer.from(value, "base64").toString("hex");
}
function collectTraceSpans(payload) {
const spans = [];
const seen = new Set();
function pushSpan(span, resource) {
if (!isObject(span) || typeof span.name !== "string") return;
const id = spanId(span) ?? `${span.name}:${spans.length}`;
const dedupeKey = `${id}:${span.name}`;
if (seen.has(dedupeKey)) return;
seen.add(dedupeKey);
spans.push({
attributes: attributesToMap(span.attributes ?? span.tags),
name: span.name,
resource,
spanId: id,
});
}
function walk(node, inheritedResource = {}) {
if (Array.isArray(node)) {
for (const item of node) walk(item, inheritedResource);
return;
}
if (!isObject(node)) return;
if (isObject(node.processes) && Array.isArray(node.spans)) {
for (const span of node.spans) {
const process = isObject(span) && typeof span.processID === "string" ? node.processes[span.processID] : undefined;
const processResource = isObject(process)
? { ...attributesToMap(process.tags), "service.name": process.serviceName }
: inheritedResource;
pushSpan(span, processResource);
}
return;
}
let resource = inheritedResource;
if (isObject(node.resource)) {
resource = { ...resource, ...attributesToMap(node.resource.attributes) };
}
for (const key of ["resourceSpans", "batches", "data", "trace"]) {
if (Array.isArray(node[key]) || isObject(node[key])) walk(node[key], resource);
}
for (const key of ["scopeSpans", "instrumentationLibrarySpans"]) {
if (Array.isArray(node[key])) walk(node[key], resource);
}
if (Array.isArray(node.spans)) {
for (const span of node.spans) pushSpan(span, resource);
}
}
walk(payload);
return spans;
}
function analyzeTrace(payload, traceId) {
const spans = collectTraceSpans(payload);
const services = [...new Set(spans.map((span) => serviceName(span.resource)).filter(Boolean))].sort();
const webSpans = spans.filter((span) => serviceName(span.resource) === "den-web");
const apiSpans = spans.filter((span) => serviceName(span.resource) === "den-api");
const honoSpan = apiSpans.find((span) => {
const route = span.attributes["http.route"];
return route === "/openapi.json" && typeof span.attributes["request.id"] === "string";
});
return {
apiSpanIds: apiSpans.map((span) => span.spanId).filter(Boolean),
hasApi: services.includes("den-api"),
hasWeb: services.includes("den-web"),
honoSpan: honoSpan
? {
name: honoSpan.name,
requestId: honoSpan.attributes["request.id"],
route: honoSpan.attributes["http.route"],
spanId: honoSpan.spanId,
}
: undefined,
services,
spanCount: spans.length,
traceId,
webSpanIds: webSpans.map((span) => span.spanId).filter(Boolean),
};
}
function collectStringField(value, names) {
if (Array.isArray(value)) {
for (const item of value) {
const found = collectStringField(item, names);
if (found) return found;
}
return undefined;
}
if (!isObject(value)) return undefined;
for (const name of names) {
const item = value[name];
if (typeof item === "string") return item;
}
for (const item of Object.values(value)) {
const found = collectStringField(item, names);
if (found) return found;
}
return undefined;
}
function collectLokiEntries(payload) {
const results = Array.isArray(payload?.data?.result) ? payload.data.result : [];
const entries = [];
for (const stream of results) {
const labels = isObject(stream.stream) ? stream.stream : {};
const values = Array.isArray(stream.values) ? stream.values : [];
for (const value of values) {
if (!Array.isArray(value)) continue;
const line = typeof value[1] === "string" ? value[1] : "";
const parsedLine = safeJsonParse(line);
const metadata = isObject(value[2]) ? value[2] : {};
const fields = {
...labels,
...metadata,
...(isObject(parsedLine) ? parsedLine : {}),
};
const text = `${line}\n${JSON.stringify(metadata)}`;
entries.push({
body: typeof fields.body === "string" ? fields.body : typeof fields.message === "string" ? fields.message : line,
fields,
line,
serviceName: fields.service_name ?? fields["service.name"] ?? fields.service,
spanId: (collectStringField(fields, ["span_id", "spanid", "spanId", "spanID"]) ?? text.match(/[0-9a-f]{16}/)?.[0])?.toLowerCase(),
text,
traceId: (collectStringField(fields, ["trace_id", "traceid", "traceId", "traceID"]) ?? text.match(/[0-9a-f]{32}/)?.[0])?.toLowerCase(),
});
}
}
return entries;
}
function summarizeLogEntry(entry) {
return {
body: entry.body,
serviceName: entry.serviceName,
spanId: entry.spanId,
traceId: entry.traceId,
};
}
function analyzeServiceLog(payload, traceId, spanIds, serviceName, bodyNeedle) {
const spanIdSet = new Set(spanIds);
const entries = collectLokiEntries(payload);
const matching = entries.find((entry) => {
const bodyMatches = bodyNeedle === undefined || entry.body.includes(bodyNeedle) || entry.line.includes(bodyNeedle);
return entry.serviceName === serviceName
&& entry.traceId === traceId
&& typeof entry.spanId === "string"
&& spanIdSet.has(entry.spanId)
&& bodyMatches;
});
return {
entries: entries.length,
match: matching ? summarizeLogEntry(matching) : undefined,
serviceName,
};
}
function analyzeMetricSeries(payload, options) {
const results = Array.isArray(payload?.data?.result) ? payload.data.result : [];
for (const item of results) {
const metric = isObject(item.metric) ? item.metric : {};
const rawValue = Array.isArray(item.value) ? item.value[1] : undefined;
const value = Number(rawValue);
if (metric.service_name !== options.serviceName) continue;
if (options.route !== undefined && metric.http_route !== options.route) continue;
if (!Number.isFinite(value)) continue;
if (options.requirePositive && value <= 0) continue;
return {
labels: metric,
name: metric.__name__,
value,
};
}
return undefined;
}
function runtimeErrorLines(logs) {
const patterns = [
/\bunhandled(?:rejection| exception)?\b/i,
/\buncaught(?:exception)?\b/i,
/UnhandledPromiseRejection/i,
/\b(?:TypeError|ReferenceError|SyntaxError|RangeError):/,
/\bERR_[A-Z0-9_]+:/,
/"level"\s*:\s*"error"/i,
/\blevel=error\b/i,
/\brequest failed\b/i,
];
return logs
.split("\n")
.filter((line) => patterns.some((pattern) => pattern.test(line)))
.slice(0, 20);
}
function stripComposePrefix(line) {
const marker = " | ";
const index = line.indexOf(marker);
return (index === -1 ? line : line.slice(index + marker.length)).trim();
}
function parseDockerJsonEntries(logs) {
const entries = [];
for (const rawLine of logs.split("\n")) {
const line = stripComposePrefix(rawLine);
const payload = safeJsonParse(line);
if (!isObject(payload)) continue;
entries.push({ line, payload, rawLine });
}
return entries;
}
function summarizeStdoutEntry(entry) {
if (entry === undefined) return undefined;
return {
durationMs: entry.payload.duration_ms,
level: entry.payload.level,
message: entry.payload.message,
route: entry.payload.http_route ?? entry.payload.upstream_path,
service: entry.payload.service,
status: entry.payload.http_status_code ?? entry.payload.status,
};
}
function exporterAttemptLines(logs) {
const patterns = [
/ECONNREFUSED.*(?:4318|otel|otlp|collector|lgtm)/i,
/(?:OTLP|otel|collector|exporter).*(?:ECONNREFUSED|failed|failure|error|connect)/i,
/(?:failed|failure|error|connect).*(?:OTLP|otel|collector|exporter|4318|lgtm)/i,
];
return logs
.split("\n")
.filter((line) => patterns.some((pattern) => pattern.test(line)))
.slice(0, 20);
}
function analyzeNoneStdout(logs, secret) {
const entries = parseDockerJsonEntries(logs);
const web = entries.find((entry) => {
return entry.payload.service === "den-web"
&& entry.payload.message === "den-web upstream proxy completed"
&& entry.payload.upstream_path === "/openapi.json"
&& Number(entry.payload.status) === 200;
});
const api = entries.find((entry) => {
return entry.payload.service === "den-api"
&& entry.payload.message === "request completed"
&& entry.payload.http_route === "/openapi.json"
&& Number(entry.payload.http_status_code) === 200;
});
return {
api: summarizeStdoutEntry(api),
dockerLogSecretAbsent: !logs.includes(secret),
exporterAttemptLines: exporterAttemptLines(logs),
jsonEntryCount: entries.length,
web: summarizeStdoutEntry(web),
};
}
async function fetchText(url, options = {}) {
const response = await fetch(url, options);
const text = await response.text();
return { response, text };
}
async function fetchJson(url, options = {}) {
const { response, text } = await fetchText(url, options);
const json = safeJsonParse(text);
if (!response.ok) {
const message = text.slice(0, 400) || response.statusText;
throw new Error(`${response.status} ${response.statusText}: ${message}`);
}
if (json === undefined) throw new Error(`Expected JSON from ${url}`);
return json;
}
function basicAuthHeader(user, password) {
return `Basic ${Buffer.from(`${user}:${password}`).toString("base64")}`;
}
function proxyUrl(grafanaUrl, uid, path) {
return `${grafanaUrl}/api/datasources/proxy/uid/${encodeURIComponent(uid)}${path}`;
}
function encodeQuery(params) {
return new URLSearchParams(params).toString();
}
function nsFromMs(ms) {
return `${BigInt(ms) * 1000000n}`;
}
function selectedDatasources(datasources) {
const byType = (type) => datasources.find((datasource) => datasource.type === type || datasource.typeName === type);
const tempo = byType("tempo");
const loki = byType("loki");
const prometheus = byType("prometheus");
if (!tempo || !loki || !prometheus) {
throw new Error(`Grafana datasources missing tempo/loki/prometheus: ${JSON.stringify(datasources)}`);
}
return { tempo, loki, prometheus };
}
async function collectSignals({ auth, datasources, endMs, grafanaUrl, secret, startMs, traceId }) {
const endNs = nsFromMs(endMs);
const startNs = nsFromMs(startMs);
const signal = {
errors: {},
metrics: {},
secretLeakEntries: undefined,
trace: undefined,
tracePayload: undefined,
logs: {},
logPayloads: {},
};
try {
const tracePayload = await fetchJson(proxyUrl(grafanaUrl, datasources.tempo.uid, `/api/traces/${traceId}`), { headers: auth });
signal.tracePayload = tracePayload;
signal.trace = analyzeTrace(tracePayload, traceId);
} catch (error) {
signal.errors.trace = error instanceof Error ? error.message : String(error);
}
try {
const query = `{service_name="den-web"} |= "den-web upstream proxy completed"`;
const params = encodeQuery({ direction: "backward", end: endNs, limit: "100", query, start: startNs });
const logPayload = await fetchJson(proxyUrl(grafanaUrl, datasources.loki.uid, `/loki/api/v1/query_range?${params}`), { headers: auth });
signal.logPayloads.web = logPayload;
signal.logs.web = signal.trace
? analyzeServiceLog(logPayload, traceId, signal.trace.webSpanIds, "den-web", "den-web upstream proxy completed")
: { entries: collectLokiEntries(logPayload).length, serviceName: "den-web" };
} catch (error) {
signal.errors.webLog = error instanceof Error ? error.message : String(error);
}
try {
const query = `{service_name="den-api"} |= "request completed"`;
const params = encodeQuery({ direction: "backward", end: endNs, limit: "100", query, start: startNs });
const logPayload = await fetchJson(proxyUrl(grafanaUrl, datasources.loki.uid, `/loki/api/v1/query_range?${params}`), { headers: auth });
signal.logPayloads.api = logPayload;
signal.logs.api = signal.trace
? analyzeServiceLog(logPayload, traceId, signal.trace.apiSpanIds, "den-api", "request completed")
: { entries: collectLokiEntries(logPayload).length, serviceName: "den-api" };
} catch (error) {
signal.errors.apiLog = error instanceof Error ? error.message : String(error);
}
try {
const query = `{service_name=~"den-web|den-api"} |= ${JSON.stringify(secret)}`;
const params = encodeQuery({ direction: "backward", end: endNs, limit: "20", query, start: startNs });
const secretPayload = await fetchJson(proxyUrl(grafanaUrl, datasources.loki.uid, `/loki/api/v1/query_range?${params}`), { headers: auth });
signal.secretLeakEntries = collectLokiEntries(secretPayload).length;
} catch (error) {
signal.errors.secretLogQuery = error instanceof Error ? error.message : String(error);
}
try {
const query = `{__name__=~"http_server_request_duration.*",service_name="den-api",http_route="/openapi.json"}`;
const params = encodeQuery({ query, time: `${Math.floor(Date.now() / 1000)}` });
const metricPayload = await fetchJson(proxyUrl(grafanaUrl, datasources.prometheus.uid, `/api/v1/query?${params}`), { headers: auth });
signal.metrics.duration = analyzeMetricSeries(metricPayload, {
requirePositive: true,
route: "/openapi.json",
serviceName: "den-api",
});
} catch (error) {
signal.errors.durationMetric = error instanceof Error ? error.message : String(error);
}
try {
const query = `{__name__=~"http_server_active_requests.*",service_name="den-api"}`;
const params = encodeQuery({ query, time: `${Math.floor(Date.now() / 1000)}` });
const metricPayload = await fetchJson(proxyUrl(grafanaUrl, datasources.prometheus.uid, `/api/v1/query?${params}`), { headers: auth });
signal.metrics.active = analyzeMetricSeries(metricPayload, {
requirePositive: false,
serviceName: "den-api",
});
} catch (error) {
signal.errors.activeMetric = error instanceof Error ? error.message : String(error);
}
signal.traceSecretAbsent = signal.tracePayload === undefined ? false : !JSON.stringify(signal.tracePayload).includes(secret);
signal.logSecretAbsent = Object.keys(signal.logPayloads).length > 0 && !JSON.stringify(signal.logPayloads).includes(secret);
signal.secretLogQueryClean = signal.secretLeakEntries === 0;
signal.ready = Boolean(
signal.trace?.hasApi
&& signal.trace?.hasWeb
&& signal.trace?.honoSpan
&& signal.logs.web?.match
&& signal.logs.api?.match
&& signal.metrics.duration
&& signal.metrics.active
&& signal.traceSecretAbsent
&& signal.logSecretAbsent
&& signal.secretLogQueryClean,
);
return signal;
}
function composeBaseArgs({ denComposeFile, otelComposeFile, project }) {
return [
"compose",
"-p",
project,
"-f",
denComposeFile,
"-f",
otelComposeFile,
];
}
function dockerCompose({ args, denComposeFile, env = {}, otelComposeFile, project }) {
return execFileSync("docker", [
...composeBaseArgs({ denComposeFile, otelComposeFile, project }),
...args,
], {
encoding: "utf8",
env: { ...process.env, ...env },
maxBuffer: 16 * 1024 * 1024,
stdio: ["ignore", "pipe", "pipe"],
});
}
function dockerLogs({ denComposeFile, otelComposeFile, project, since }) {
const args = [
...composeBaseArgs({ denComposeFile, otelComposeFile, project }),
"logs",
"--no-color",
];
if (since) args.push("--since", since);
args.push("den", "web");
return execFileSync("docker", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
}
async function requestOpenApi({ requestPath, secret, traceId = randomBytes(16).toString("hex"), webUrl }) {
const parentSpanId = randomBytes(8).toString("hex");
const traceparent = `00-${traceId}-${parentSpanId}-01`;
const requestResponse = await fetch(`${webUrl}${requestPath}`, {
headers: {
traceparent,
"x-openwork-otel-hono-e2e": traceId,
},
});
const requestBody = await requestResponse.text();
const openApi = safeJsonParse(requestBody);
return {
body: requestBody,
report: {
contentType: requestResponse.headers.get("content-type"),
path: requestPath.replaceAll(secret, "[redacted]"),
status: requestResponse.status,
title: openApi?.info?.title,
traceparent,
},
response: requestResponse,
traceId,
};
}
async function runNonePhase({ denComposeFile, otelComposeFile, project, requestPath, secret, waitSeconds, webUrl }) {
const since = new Date(Date.now() - 1000).toISOString();
const recreateOutput = dockerCompose({
args: ["up", "-d", "--wait", "--wait-timeout", String(waitSeconds), "--no-build", "den", "web"],
denComposeFile,
env: {
DEN_OBSERVABILITY_BACKEND: "none",
NEXT_PUBLIC_DEN_OBSERVABILITY_BACKEND: "none",
},
otelComposeFile,
project,
});
const request = await requestOpenApi({ requestPath, secret, webUrl });
if (request.response.status !== 200) {
throw new Error(`Expected HTTP 200 from none backend ${request.report.path}; got ${request.response.status}: ${request.body.slice(0, 400)}`);
}
let logs = "";
let analysis = analyzeNoneStdout(logs, secret);
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
logs = dockerLogs({ denComposeFile, otelComposeFile, project, since });
analysis = analyzeNoneStdout(logs, secret);
if (analysis.api && analysis.web) break;
await sleep(2_000);
}
return {
logs: analysis,
recreateOutput: recreateOutput.trim(),
request: request.report,
since,
};
}
function addAssertion(report, name, ok, evidence) {
report.assertions.push({ evidence, name, ok });
}
function printSummary(report) {
const status = report.ok ? "PASS" : "FAIL";
console.log(`${status} otel-hono-live`);
console.log(`report: ${report.reportJson}`);
console.log(`request: ${report.request?.status ?? "n/a"} ${report.request?.path ?? ""}`.trim());
if (report.evidence?.trace) {
const trace = report.evidence.trace;
console.log(`trace: ${trace.traceId} services=${trace.services.join(",")} hono=${trace.honoSpan?.name ?? "missing"} request.id=${trace.honoSpan?.requestId ?? "missing"}`);
}
if (report.evidence?.logs?.web?.match) {
const log = report.evidence.logs.web.match;
console.log(`log: den-web trace_id=${log.traceId} span_id=${log.spanId}`);
}
if (report.evidence?.logs?.api?.match) {
const log = report.evidence.logs.api.match;
console.log(`log: den-api trace_id=${log.traceId} span_id=${log.spanId}`);
}
if (report.evidence?.metrics?.duration) {
console.log(`metric: ${report.evidence.metrics.duration.name} route=/openapi.json value=${report.evidence.metrics.duration.value}`);
}
if (report.evidence?.metrics?.active) {
console.log(`metric: ${report.evidence.metrics.active.name} active_value=${report.evidence.metrics.active.value}`);
}
if (report.evidence?.none) {
const none = report.evidence.none;
console.log(`none: ${none.request.status} stdout_web=${Boolean(none.logs.web)} stdout_api=${Boolean(none.logs.api)} exporter_attempts=${none.logs.exporterAttemptLines.length}`);
}
if (!report.ok && report.error) console.error(report.error);
}
async function main() {
const reportJson = requiredArg("report-json");
const grafanaUrl = trimTrailingSlash(requiredArg("grafana-url"));
const webUrl = trimTrailingSlash(requiredArg("web-url"));
const project = requiredArg("project");
const denComposeFile = requiredArg("den-compose-file");
const otelComposeFile = requiredArg("otel-compose-file");
const secret = argValue("secret", process.env.OTEL_HONO_SECRET ?? "super-secret");
const requestPath = argValue("request-path", `/api/den/openapi.json?token=${encodeURIComponent(secret)}`);
const timeoutMs = Number(process.env.OTEL_HONO_POLL_SECONDS ?? "240") * 1000;
const intervalMs = Number(process.env.OTEL_HONO_POLL_INTERVAL_SECONDS ?? "5") * 1000;
const grafanaUser = process.env.OTEL_HONO_GRAFANA_USER ?? "admin";
const grafanaPassword = process.env.OTEL_HONO_GRAFANA_PASSWORD ?? "admin";
const traceId = process.env.OTEL_HONO_TRACE_ID ?? randomBytes(16).toString("hex");
const waitSeconds = process.env.OTEL_HONO_COMPOSE_WAIT_SECONDS ?? "420";
assertHex(traceId, 32, "OTEL_HONO_TRACE_ID");
const report = {
assertions: [],
datasources: undefined,
endedAt: undefined,
error: undefined,
evidence: {},
ok: false,
ports: {
denApi: process.env.DEN_API_PORT,
denWeb: process.env.DEN_WEB_PORT,
grafana: process.env.OTEL_LGTM_GRAFANA_PORT,
otlpHttp: process.env.OTEL_LGTM_OTLP_HTTP_PORT,
},
project,
reportJson,
request: undefined,
startedAt: nowIso(),
};
try {
const auth = { Authorization: basicAuthHeader(grafanaUser, grafanaPassword) };
await fetchJson(`${grafanaUrl}/api/health`, { headers: auth });
const datasources = selectedDatasources(await fetchJson(`${grafanaUrl}/api/datasources`, { headers: auth }));
report.datasources = {
loki: { name: datasources.loki.name, type: datasources.loki.type, uid: datasources.loki.uid },
prometheus: { name: datasources.prometheus.name, type: datasources.prometheus.type, uid: datasources.prometheus.uid },
tempo: { name: datasources.tempo.name, type: datasources.tempo.type, uid: datasources.tempo.uid },
};
const startMs = Date.now() - 30_000;
const otelRequest = await requestOpenApi({ requestPath, secret, traceId, webUrl });
report.request = otelRequest.report;
addAssertion(report, "http_200", otelRequest.response.status === 200, report.request);
if (otelRequest.response.status !== 200) {
throw new Error(`Expected HTTP 200 from ${otelRequest.report.path}; got ${otelRequest.response.status}: ${otelRequest.body.slice(0, 400)}`);
}
let lastSignal;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
lastSignal = await collectSignals({
auth,
datasources,
endMs: Date.now() + 5_000,
grafanaUrl,
secret,
startMs,
traceId,
});
if (lastSignal.ready) break;
await sleep(intervalMs);
}
report.evidence.poll = {
attemptsEndedAt: nowIso(),
lastErrors: lastSignal?.errors,
timeoutSeconds: timeoutMs / 1000,
};
if (!lastSignal?.ready) {
report.evidence.lastSignal = {
logs: lastSignal?.logs,
metrics: lastSignal?.metrics,
secretLeakEntries: lastSignal?.secretLeakEntries,
trace: lastSignal?.trace,
};
throw new Error("Timed out polling Grafana datasource proxy APIs for trace, log, and metric evidence");
}
report.evidence.trace = lastSignal.trace;
report.evidence.logs = lastSignal.logs;
report.evidence.log = lastSignal.logs.web;
report.evidence.metrics = lastSignal.metrics;
report.evidence.metric = lastSignal.metrics.duration;
report.evidence.secret = {
lokiSecretQueryEntries: lastSignal.secretLeakEntries,
logPayloadAbsent: lastSignal.logSecretAbsent,
tracePayloadAbsent: lastSignal.traceSecretAbsent,
};
addAssertion(report, "trace_contains_den_web_and_den_api", lastSignal.trace.hasApi && lastSignal.trace.hasWeb, lastSignal.trace.services);
addAssertion(report, "hono_openapi_span_has_request_id", Boolean(lastSignal.trace.honoSpan), lastSignal.trace.honoSpan);
addAssertion(report, "secret_absent_from_trace_and_loki_log_output", lastSignal.traceSecretAbsent && lastSignal.logSecretAbsent && lastSignal.secretLeakEntries === 0, report.evidence.secret);
addAssertion(report, "den_web_log_trace_span_matches_trace", Boolean(lastSignal.logs.web.match), lastSignal.logs.web.match);
addAssertion(report, "den_api_log_trace_span_matches_trace", Boolean(lastSignal.logs.api.match), lastSignal.logs.api.match);
addAssertion(report, "den_api_hono_duration_metric_exists", Boolean(lastSignal.metrics.duration), lastSignal.metrics.duration);
addAssertion(report, "den_api_hono_active_requests_metric_exists", Boolean(lastSignal.metrics.active), lastSignal.metrics.active);
const none = await runNonePhase({
denComposeFile,
otelComposeFile,
project,
requestPath,
secret,
waitSeconds,
webUrl,
});
report.evidence.none = none;
report.none = none;
addAssertion(report, "none_backend_http_200", none.request.status === 200, none.request);
addAssertion(report, "none_backend_json_stdout_request_logs", Boolean(none.logs.web && none.logs.api), none.logs);
addAssertion(report, "none_backend_no_exporter_connection_or_error_attempt", none.logs.exporterAttemptLines.length === 0, { exporterAttemptLines: none.logs.exporterAttemptLines });
addAssertion(report, "none_backend_secret_absent_from_docker_logs", none.logs.dockerLogSecretAbsent, { dockerLogSecretAbsent: none.logs.dockerLogSecretAbsent });
const logs = dockerLogs({ denComposeFile, otelComposeFile, project });
const errors = runtimeErrorLines(logs);
const dockerLogSecretAbsent = !logs.includes(secret);
report.evidence.runtime = {
dockerLogSecretAbsent,
errorLines: errors,
inspectedServices: ["den", "web"],
};
addAssertion(report, "secret_absent_from_docker_runtime_logs", dockerLogSecretAbsent, { dockerLogSecretAbsent });
addAssertion(report, "no_unhandled_runtime_errors", errors.length === 0, { errorLines: errors });
report.ok = report.assertions.every((assertion) => assertion.ok);
if (!report.ok) throw new Error("One or more live validator assertions failed");
} catch (error) {
const message = error instanceof Error ? error.stack ?? error.message : String(error);
report.error = message.replaceAll(secret, "[redacted]");
} finally {
report.endedAt = nowIso();
await mkdir(dirname(reportJson), { recursive: true });
await writeFile(reportJson, `${JSON.stringify(report, null, 2)}\n`);
printSummary(report);
}
if (!report.ok) process.exit(1);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
process.exit(1);
});
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
project_default="openwork-otel-hono-live-$$"
project="${OTEL_HONO_PROJECT:-$project_default}"
den_compose_file="${OTEL_HONO_DEN_COMPOSE_FILE:-$repo_root/packaging/docker/docker-compose.den-dev.yml}"
otel_compose_file="${OTEL_HONO_LGTM_COMPOSE_FILE:-$repo_root/packaging/docker/docker-compose.otel-lgtm.yml}"
keep_stack="${KEEP_STACK:-0}"
skip_build="${SKIP_BUILD:-0}"
wait_seconds="${OTEL_HONO_COMPOSE_WAIT_SECONDS:-420}"
port_base="${OTEL_HONO_PORT_BASE:-$((22000 + ($$ % 1000) * 20))}"
case "$den_compose_file" in
/*) ;;
*) den_compose_file="$repo_root/$den_compose_file" ;;
esac
case "$otel_compose_file" in
/*) ;;
*) otel_compose_file="$repo_root/$otel_compose_file" ;;
esac
if [[ ! "$project" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
echo "OTEL_HONO_PROJECT must start with a lowercase letter or digit and contain only lowercase letters, digits, underscores, or dashes" >&2
exit 1
fi
for file in "$den_compose_file" "$otel_compose_file" "$script_dir/otel-hono-live-validate.mjs"; do
if [ ! -f "$file" ]; then
echo "Required file not found: $file" >&2
exit 1
fi
done
for command in docker node; do
if ! command -v "$command" >/dev/null 2>&1; then
echo "$command is required for live OTEL/Hono validation" >&2
exit 1
fi
done
export DEN_API_PORT="${DEN_API_PORT:-$((port_base + 1))}"
export DEN_WEB_PORT="${DEN_WEB_PORT:-$((port_base + 2))}"
export DEN_MYSQL_PORT="${DEN_MYSQL_PORT:-$((port_base + 3))}"
export OTEL_LGTM_GRAFANA_PORT="${OTEL_LGTM_GRAFANA_PORT:-$((port_base + 4))}"
export OTEL_LGTM_OTLP_GRPC_PORT="${OTEL_LGTM_OTLP_GRPC_PORT:-$((port_base + 5))}"
export OTEL_LGTM_OTLP_HTTP_PORT="${OTEL_LGTM_OTLP_HTTP_PORT:-$((port_base + 6))}"
export DEN_OBSERVABILITY_BACKEND="${DEN_OBSERVABILITY_BACKEND:-otel}"
export NEXT_PUBLIC_DEN_OBSERVABILITY_BACKEND="${NEXT_PUBLIC_DEN_OBSERVABILITY_BACKEND:-otel}"
export OTEL_EXPORTER_OTLP_ENDPOINT="${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-lgtm:4318}"
export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT="${OTEL_EXPORTER_OTLP_TRACES_ENDPOINT:-http://otel-lgtm:4318/v1/traces}"
export OTEL_EXPORTER_OTLP_METRICS_ENDPOINT="${OTEL_EXPORTER_OTLP_METRICS_ENDPOINT:-http://otel-lgtm:4318/v1/metrics}"
export OTEL_EXPORTER_OTLP_LOGS_ENDPOINT="${OTEL_EXPORTER_OTLP_LOGS_ENDPOINT:-http://otel-lgtm:4318/v1/logs}"
export OTEL_EXPORTER_OTLP_PROTOCOL="${OTEL_EXPORTER_OTLP_PROTOCOL:-http/protobuf}"
export OTEL_EXPORTER_OTLP_TRACES_PROTOCOL="${OTEL_EXPORTER_OTLP_TRACES_PROTOCOL:-http/protobuf}"
export OTEL_EXPORTER_OTLP_METRICS_PROTOCOL="${OTEL_EXPORTER_OTLP_METRICS_PROTOCOL:-http/protobuf}"
export OTEL_EXPORTER_OTLP_LOGS_PROTOCOL="${OTEL_EXPORTER_OTLP_LOGS_PROTOCOL:-http/protobuf}"
export OTEL_TRACES_EXPORTER="${OTEL_TRACES_EXPORTER:-otlp}"
export OTEL_METRICS_EXPORTER="${OTEL_METRICS_EXPORTER:-otlp}"
export OTEL_LOGS_EXPORTER="${OTEL_LOGS_EXPORTER:-otlp}"
export OTEL_TRACES_SAMPLER="${OTEL_TRACES_SAMPLER:-parentbased_always_on}"
export DEN_API_OTEL_SERVICE_NAME="${DEN_API_OTEL_SERVICE_NAME:-den-api}"
export DEN_WEB_OTEL_SERVICE_NAME="${DEN_WEB_OTEL_SERVICE_NAME:-den-web}"
export DEN_BETTER_AUTH_URL="${DEN_BETTER_AUTH_URL:-http://127.0.0.1:${DEN_WEB_PORT}}"
export DEN_API_PUBLIC_URL="${DEN_API_PUBLIC_URL:-http://127.0.0.1:${DEN_API_PORT}}"
export DEN_MCP_RESOURCE_URL="${DEN_MCP_RESOURCE_URL:-http://127.0.0.1:${DEN_API_PORT}/mcp}"
export DEN_CORS_ORIGINS="${DEN_CORS_ORIGINS:-http://127.0.0.1:${DEN_WEB_PORT},http://localhost:${DEN_WEB_PORT},http://127.0.0.1:${DEN_API_PORT},http://localhost:${DEN_API_PORT}}"
export DEN_BETTER_AUTH_TRUSTED_ORIGINS="${DEN_BETTER_AUTH_TRUSTED_ORIGINS:-$DEN_CORS_ORIGINS}"
report_dir="${OTEL_HONO_REPORT_DIR:-$repo_root/tmp/otel-hono-live-$project}"
report_json="${OTEL_HONO_REPORT_JSON:-$report_dir/report.json}"
docker_log="$report_dir/docker.log"
mkdir -p "$report_dir"
cleanup() {
status=$?
if [ "$keep_stack" != "1" ]; then
docker compose -p "$project" -f "$den_compose_file" -f "$otel_compose_file" down --volumes >>"$docker_log" 2>&1 || true
else
echo "KEEP_STACK=1; leaving Compose project $project running" >&2
fi
exit "$status"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
run_step() {
label="$1"
shift
echo "==> $label"
if "$@" >>"$docker_log" 2>&1; then
echo "ok: $label"
return
fi
echo "failed: $label (see $docker_log)" >&2
tail -n 120 "$docker_log" >&2 || true
exit 1
}
echo "report_json=$report_json"
echo "project=$project"
echo "ports den_web=$DEN_WEB_PORT den_api=$DEN_API_PORT mysql=$DEN_MYSQL_PORT grafana=$OTEL_LGTM_GRAFANA_PORT otlp_http=$OTEL_LGTM_OTLP_HTTP_PORT"
run_step "render merged compose config" docker compose -p "$project" -f "$den_compose_file" -f "$otel_compose_file" config
if [ "$skip_build" != "1" ]; then
run_step "build Den API and Den Web images" docker compose -p "$project" -f "$den_compose_file" -f "$otel_compose_file" build den web
else
echo "SKIP_BUILD=1; reusing existing images"
fi
run_step "start and wait for live Den + OTEL LGTM stack" docker compose -p "$project" -f "$den_compose_file" -f "$otel_compose_file" up -d --wait --wait-timeout "$wait_seconds" mysql otel-lgtm den web
node "$script_dir/otel-hono-live-validate.mjs" \
--project "$project" \
--den-compose-file "$den_compose_file" \
--otel-compose-file "$otel_compose_file" \
--grafana-url "http://127.0.0.1:${OTEL_LGTM_GRAFANA_PORT}" \
--web-url "http://127.0.0.1:${DEN_WEB_PORT}" \
--report-json "$report_json"
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/../.." && pwd)"
project="${OTEL_LGTM_PROJECT:-openwork-otel-lgtm-validate-$$}"
compose_file="${OTEL_LGTM_COMPOSE_FILE:-$repo_root/packaging/docker/docker-compose.otel-lgtm.yml}"
grafana_port="${OTEL_LGTM_GRAFANA_PORT:-3000}"
otlp_http_port="${OTEL_LGTM_OTLP_HTTP_PORT:-4318}"
keep_running="${OTEL_LGTM_KEEP_RUNNING:-0}"
if [[ ! "$project" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
echo "OTEL_LGTM_PROJECT must start with a lowercase letter or digit and contain only lowercase letters, digits, underscores, or dashes" >&2
exit 1
fi
case "$compose_file" in
/*) ;;
*) compose_file="$repo_root/$compose_file" ;;
esac
if [ ! -f "$compose_file" ]; then
echo "Compose file not found: $compose_file" >&2
exit 1
fi
if ! command -v docker >/dev/null 2>&1; then
echo "docker is required to validate the OTEL LGTM stack" >&2
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "curl is required to validate the OTLP HTTP receiver" >&2
exit 1
fi
cleanup() {
status=$?
if [ "$keep_running" != "1" ]; then
docker compose -p "$project" -f "$compose_file" down --volumes >/dev/null 2>&1 || true
fi
exit "$status"
}
trap cleanup EXIT
docker compose -p "$project" -f "$compose_file" config >/dev/null
docker compose -p "$project" -f "$compose_file" up -d --wait otel-lgtm
container_id="$(docker compose -p "$project" -f "$compose_file" ps -q otel-lgtm)"
health_status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$container_id")"
if [ "$health_status" != "healthy" ]; then
echo "otel-lgtm container is not healthy: $health_status" >&2
exit 1
fi
curl -fsS "http://127.0.0.1:${grafana_port}/api/health" >/dev/null
for signal in traces metrics logs; do
curl -fsS \
-X POST \
-H "Content-Type: application/x-protobuf" \
--data-binary "" \
"http://127.0.0.1:${otlp_http_port}/v1/${signal}" >/dev/null
done
echo "otel-lgtm is healthy and accepts OTLP/HTTP protobuf on port ${otlp_http_port}"