mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
feat: add docker support (#848)
* feat: add docker support * feat: move docker files to root * feat: add docker build and push workflow * fix: pin docker action SHAs to verified commits Made-with: Cursor * fix: remove redundant --platform=$TARGETPLATFORM from runtime stage Made-with: Cursor * fix: upgrade docker actions to Node.js 24-compatible versions Made-with: Cursor * docs: updated readme * fix: update docker references * fix(docker-server): reject null bytes in resolvePath Defensively harden the path traversal guard by returning null early when the URL contains a null byte, before normalization runs. Made-with: Cursor * fix(docker-server): handle createReadStream errors Attach an error listener before piping so mid-flight read errors (truncated file, permission change) cleanly destroy the response instead of being silently swallowed. Made-with: Cursor * fix(docker-server): replace existsSync with async stat Eliminates the TOCTOU race between the initial stat call and the subsequent existsSync check. Reuses the async stat pattern already in place and removes the now-unused existsSync import. Made-with: Cursor * test(docker-server): add integration tests; fix %00 null-byte bypass Decode the URL before the null-byte check so percent-encoded null bytes (%00) are also rejected with 400 instead of falling through to the SPA fallback. Adds 5 node:test integration tests covering valid assets, SPA fallback, path traversal, null bytes, and 404. Made-with: Cursor * style: fix prettier formatting in docker-server files Made-with: Cursor * fix(docker): wire tests into CI, fix resolvePath separator, correct image namespace - Add `node --test docker-server.test.mjs` step to ci-tests.yml so the path-traversal guard tests run in every CI pass instead of being silently skipped. - Fix resolvePath containment check: `startsWith(root)` would allow sibling directories like `/app/dist-evil/`; now guards with `root + sep` or exact match. - Update docker-compose.yaml default image from `abhigyanpatwari` namespace to `brainifii` to match what docker.yml publishes to GHCR. * fix(docker): update apt-get commands and set user permissions - Modify Dockerfile and Dockerfile.test to include options for apt-get to bypass validity checks during updates. - Set ownership of the /app directory to the 'node' user in the runtime stage for improved security and proper permission handling. * fix(docker): switch to Alpine base images for smaller footprint - Update Dockerfile to use Alpine-based Node.js images for both builder and runtime stages, reducing image size and improving performance. - Replace apt-get commands with apk for package installation in the runtime stage. * fix(docker): update Node.js version in Dockerfile - Change base image from node:20-alpine to node:22-alpine * fix(docker): update Node.js version in Dockerfile to 22-alpine for runtime --------- Co-authored-by: kritik.b <kritik.b@media.net>
This commit is contained in:
parent
725ed3fe66
commit
040bb7a489
13 changed files with 373 additions and 1 deletions
1
.cursor/.gitignore
vendored
Normal file
1
.cursor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
plans/
|
||||
19
.dockerignore
Normal file
19
.dockerignore
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
.git
|
||||
.gitignore
|
||||
.DS_Store
|
||||
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
dist
|
||||
**/dist
|
||||
coverage
|
||||
**/coverage
|
||||
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
.gitnexus
|
||||
gitnexus-web/playwright-report
|
||||
gitnexus-web/test-results
|
||||
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
IMAGE_NAME=ghcr.io/abhigyanpatwari/gitnexus:latest
|
||||
CONTAINER_NAME=gitnexus
|
||||
HOST_PORT=4173
|
||||
3
.github/workflows/ci-tests.yml
vendored
3
.github/workflows/ci-tests.yml
vendored
|
|
@ -41,6 +41,9 @@ jobs:
|
|||
--outputFile=web-test-results.json
|
||||
working-directory: gitnexus-web
|
||||
|
||||
- name: Run docker-server integration tests
|
||||
run: node --test docker-server.test.mjs
|
||||
|
||||
- name: Upload test reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
|
|
|||
71
.github/workflows/docker.yml
vendored
Normal file
71
.github/workflows/docker.yml
vendored
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
name: Docker Build & Push
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
branches:
|
||||
- main
|
||||
paths-ignore: ['**.md', 'docs/**', 'LICENSE']
|
||||
workflow_dispatch:
|
||||
|
||||
# Concurrency convention: see CONTRIBUTING.md → "GitHub Actions — Concurrency Convention".
|
||||
# Tag refs are unique per release — distinct tags run in parallel.
|
||||
# Pushes to main serialize; cancel superseded runs.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: ${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
jobs:
|
||||
build-push:
|
||||
name: Build & Push image
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# Required for multi-platform (linux/arm64) emulation.
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Computes image tags and labels from Git metadata:
|
||||
# v* tag → ghcr.io/<owner>/<repo>:<semver> (e.g. 1.2.3, 1.2, 1)
|
||||
# main push → ghcr.io/<owner>/<repo>:latest
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=sha,prefix=sha-,format=short
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
BUILDPLATFORM=${{ runner.os == 'Linux' && 'linux/amd64' || 'linux/amd64' }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -23,6 +23,7 @@ Thumbs.db
|
|||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
docker/.env
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
|
|
|||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
ARG BUILDPLATFORM
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY gitnexus-shared/package.json gitnexus-shared/package-lock.json ./gitnexus-shared/
|
||||
RUN npm ci --prefix gitnexus-shared
|
||||
|
||||
COPY gitnexus-shared ./gitnexus-shared
|
||||
RUN npm run build --prefix gitnexus-shared
|
||||
|
||||
COPY gitnexus/package.json ./gitnexus/package.json
|
||||
COPY gitnexus-web/package.json gitnexus-web/package-lock.json ./gitnexus-web/
|
||||
RUN npm ci --prefix gitnexus-web
|
||||
|
||||
COPY gitnexus-web ./gitnexus-web
|
||||
RUN npm run build --prefix gitnexus-web
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
RUN apk add --no-cache curl
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/gitnexus-web/dist ./dist
|
||||
COPY docker-server.mjs ./docker-server.mjs
|
||||
|
||||
RUN chown -R node:node /app
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 4173
|
||||
|
||||
CMD ["node", "docker-server.mjs"]
|
||||
36
README.md
36
README.md
|
|
@ -335,6 +335,42 @@ cd ../gitnexus-web && npm install
|
|||
npm run dev
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
||||
```bash
|
||||
docker run --rm \
|
||||
--name gitnexus \
|
||||
-p 4173:4173 \
|
||||
ghcr.io/abhigyanpatwari/gitnexus:latest
|
||||
```
|
||||
|
||||
Or with Docker Compose:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Optional env file:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
set -a
|
||||
source .env
|
||||
set +a
|
||||
```
|
||||
|
||||
Docker files:
|
||||
|
||||
- [Dockerfile](Dockerfile) is the source for the published `gitnexus` image. It builds `gitnexus-shared` and `gitnexus-web`, then serves the production frontend.
|
||||
- [docker-compose.yaml](docker-compose.yaml) starts the published image with Docker Compose.
|
||||
- [.env.example](.env.example) sets the image name, container name, and exposed port for the example commands.
|
||||
|
||||
Notes:
|
||||
|
||||
- The published image serves the production frontend only. It does not start `gitnexus serve`.
|
||||
- In backend mode, the app still defaults to `http://localhost:4747` unless you change the server URL in the UI.
|
||||
- If you do not want an env file, the defaults are `ghcr.io/abhigyanpatwari/gitnexus:latest`, container name `gitnexus`, and port `4173`.
|
||||
|
||||
The web UI uses the same indexing pipeline as the CLI but runs entirely in WebAssembly (Tree-sitter WASM, LadybugDB WASM, in-browser embeddings). It's great for quick exploration but limited by browser memory for larger repos.
|
||||
|
||||
**Local Backend Mode:** Run `gitnexus serve` and open the web UI locally — it auto-detects the server and shows all your indexed repos, with full AI chat support. No need to re-upload or re-index. The agent's tools (Cypher queries, search, code navigation) route through the backend HTTP API automatically.
|
||||
|
|
|
|||
13
docker-compose.yaml
Normal file
13
docker-compose.yaml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
services:
|
||||
gitnexus:
|
||||
image: ${IMAGE_NAME:-ghcr.io/brainifii/gitnexus:latest}
|
||||
container_name: ${CONTAINER_NAME:-gitnexus}
|
||||
ports:
|
||||
- '${HOST_PORT:-4173}:4173'
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ['CMD', 'curl', '-f', 'http://localhost:4173/']
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
81
docker-server.mjs
Normal file
81
docker-server.mjs
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { createReadStream } from 'node:fs';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import { extname, join, normalize, sep } from 'node:path';
|
||||
|
||||
const host = '0.0.0.0';
|
||||
const port = Number(process.env.PORT || '4173');
|
||||
const root = join(process.cwd(), 'dist');
|
||||
|
||||
const contentTypes = {
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.js': 'text/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.map': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.txt': 'text/plain; charset=utf-8',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
};
|
||||
|
||||
function resolvePath(urlPath) {
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeURIComponent(urlPath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (decoded.includes('\0')) return null;
|
||||
const cleanPath = normalize(decoded.replace(/^\/+/, ''));
|
||||
const candidate = join(root, cleanPath);
|
||||
if (candidate !== root && !candidate.startsWith(root + sep)) return null;
|
||||
return candidate;
|
||||
}
|
||||
|
||||
const server = createServer(async (req, res) => {
|
||||
const requestPath = req.url?.split('?')[0] || '/';
|
||||
let filePath = resolvePath(requestPath);
|
||||
|
||||
if (!filePath) {
|
||||
res.writeHead(400);
|
||||
res.end('Bad request');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const fileStat = await stat(filePath).catch(() => null);
|
||||
if (fileStat?.isDirectory()) {
|
||||
filePath = join(filePath, 'index.html');
|
||||
} else if (!fileStat?.isFile()) {
|
||||
filePath = join(root, 'index.html');
|
||||
}
|
||||
|
||||
const finalStat = await stat(filePath).catch(() => null);
|
||||
if (!finalStat?.isFile()) {
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
'Cache-Control': filePath.includes('/assets/')
|
||||
? 'public, max-age=31536000, immutable'
|
||||
: 'no-cache',
|
||||
'Content-Type': contentTypes[extname(filePath)] || 'application/octet-stream',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
});
|
||||
const stream = createReadStream(filePath);
|
||||
stream.on('error', () => res.destroy());
|
||||
stream.pipe(res);
|
||||
} catch (error) {
|
||||
res.writeHead(500);
|
||||
res.end(error instanceof Error ? error.message : 'Internal server error');
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`gitnexus-web listening on http://${host}:${port}`);
|
||||
});
|
||||
107
docker-server.test.mjs
Normal file
107
docker-server.test.mjs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { mkdir, mkdtemp, rm, unlink, writeFile } from 'node:fs/promises';
|
||||
import http, { createServer } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { after, before, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const serverScript = join(__dirname, 'docker-server.mjs');
|
||||
|
||||
function getFreePort() {
|
||||
return new Promise((resolve) => {
|
||||
const s = createServer();
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const { port } = s.address();
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rawGet(port, path) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request({ host: '127.0.0.1', port, path }, (res) => {
|
||||
let body = '';
|
||||
res.setEncoding('utf8');
|
||||
res.on('data', (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForServer(port, retries = 30) {
|
||||
for (let i = 0; i < retries; i++) {
|
||||
try {
|
||||
await rawGet(port, '/');
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
throw new Error('Server did not start in time');
|
||||
}
|
||||
|
||||
let tmpDir, serverPort, child;
|
||||
|
||||
before(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'gitnexus-docker-test-'));
|
||||
const distDir = join(tmpDir, 'dist');
|
||||
const assetsDir = join(distDir, 'assets');
|
||||
await mkdir(assetsDir, { recursive: true });
|
||||
await writeFile(join(distDir, 'index.html'), '<html><body>spa</body></html>');
|
||||
await writeFile(join(assetsDir, 'app.abc123.js'), 'console.log("app")');
|
||||
|
||||
serverPort = await getFreePort();
|
||||
child = spawn(process.execPath, [serverScript], {
|
||||
cwd: tmpDir,
|
||||
env: { ...process.env, PORT: String(serverPort) },
|
||||
stdio: 'pipe',
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
await waitForServer(serverPort);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
child?.kill();
|
||||
if (tmpDir) await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('serves a valid asset with immutable cache header', async () => {
|
||||
const res = await rawGet(serverPort, '/assets/app.abc123.js');
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers['cache-control'], /immutable/);
|
||||
assert.equal(res.headers['cross-origin-opener-policy'], 'same-origin');
|
||||
assert.equal(res.headers['cross-origin-embedder-policy'], 'require-corp');
|
||||
});
|
||||
|
||||
it('serves SPA fallback for unknown routes', async () => {
|
||||
const res = await rawGet(serverPort, '/some/unknown/route');
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.body, /spa/);
|
||||
assert.match(res.headers['cache-control'], /no-cache/);
|
||||
});
|
||||
|
||||
it('rejects path traversal with 400', async () => {
|
||||
const res = await rawGet(serverPort, '/../../../etc/passwd');
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('rejects percent-encoded null bytes with 400', async () => {
|
||||
const res = await rawGet(serverPort, '/foo%00bar');
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
it('returns 404 when dist/index.html is missing', async () => {
|
||||
await unlink(join(tmpDir, 'dist', 'index.html'));
|
||||
const res = await rawGet(serverPort, '/nonexistent-page');
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ export default defineConfig({
|
|||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
'@shared': path.resolve(__dirname, '../shared'),
|
||||
'gitnexus-shared': path.resolve(__dirname, '../gitnexus-shared/src/index.ts'),
|
||||
// Fix for Rollup failing to resolve this deep import from @langchain/anthropic
|
||||
'@anthropic-ai/sdk/lib/transform-json-schema': path.resolve(
|
||||
__dirname,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
FROM node:20-bookworm
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get -o Acquire::Check-Valid-Until=false -o Acquire::Check-Date=false update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
|
||||
COPY . .
|
||||
RUN npm ci --ignore-scripts \
|
||||
&& node scripts/patch-tree-sitter-swift.cjs \
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue