源码快照 @ 2026-09-02 (source-only, 剔除非源码素材)
This commit is contained in:
@@ -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}\""]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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}"]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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).
|
||||
Executable
+301
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -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
|
||||
Executable
+55
@@ -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
|
||||
Executable
+778
@@ -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);
|
||||
});
|
||||
Executable
+123
@@ -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"
|
||||
Executable
+66
@@ -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}"
|
||||
Reference in New Issue
Block a user