mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(devenv): MCP dev hot-reload with image-based approach (#145)
Add mcp-build.sh that compiles the MCP binary and packages it as an OCI image layer on top of the base rootfs, imported directly into containerd. Air triggers rebuild on code changes, cleaning stale containers automatically. Consolidate dev-only files (Dockerfiles, entrypoint, config, build script) into devenv/ to separate dev tooling from production artifacts. Skip image pull when already imported to speed up dev startup.
This commit is contained in:
@@ -2,7 +2,7 @@ root = "."
|
|||||||
tmp_dir = "tmp"
|
tmp_dir = "tmp"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
cmd = "go build -o ./tmp/memoh-server ./cmd/agent/main.go"
|
cmd = "go build -o ./tmp/memoh-server ./cmd/agent/main.go && sh devenv/mcp-build.sh"
|
||||||
bin = "./tmp/memoh-server"
|
bin = "./tmp/memoh-server"
|
||||||
args_bin = ["serve"]
|
args_bin = ["serve"]
|
||||||
include_ext = ["go", "toml"]
|
include_ext = ["go", "toml"]
|
||||||
|
|||||||
@@ -125,18 +125,11 @@ Memoh/
|
|||||||
├── db/ # Database
|
├── db/ # Database
|
||||||
│ ├── migrations/ # SQL migration files
|
│ ├── migrations/ # SQL migration files
|
||||||
│ └── queries/ # SQL query files (sqlc input)
|
│ └── queries/ # SQL query files (sqlc input)
|
||||||
├── conf/ # Configuration templates
|
├── conf/ # Configuration templates (app.example.toml, app.docker.toml)
|
||||||
│ ├── app.example.toml # Default configuration template
|
├── devenv/ # Dev environment (docker-compose, dev Dockerfiles, app.dev.toml, mcp-build.sh)
|
||||||
│ ├── app.dev.toml # Development configuration
|
├── docker/ # Production Docker build & runtime (Dockerfiles, entrypoints, nginx.conf)
|
||||||
│ ├── app.docker.toml # Docker deployment configuration
|
|
||||||
│ ├── app.apple.toml # macOS (Apple Virtualization) configuration
|
|
||||||
│ └── app.windows.toml # Windows configuration
|
|
||||||
├── devenv/ # Development environment (docker-compose for local infra)
|
|
||||||
├── docker/ # Docker build & runtime (Dockerfiles, entrypoints, nginx)
|
|
||||||
├── docs/ # Documentation site
|
├── docs/ # Documentation site
|
||||||
├── scripts/ # Utility scripts
|
├── scripts/ # Utility scripts (db, release, install)
|
||||||
├── assets/ # Static assets (images, etc.)
|
|
||||||
├── data/ # Runtime data directory
|
|
||||||
├── docker-compose.yml # Docker Compose orchestration (production)
|
├── docker-compose.yml # Docker Compose orchestration (production)
|
||||||
├── mise.toml # mise tasks and tool version definitions
|
├── mise.toml # mise tasks and tool version definitions
|
||||||
├── sqlc.yaml # sqlc code generation config
|
├── sqlc.yaml # sqlc code generation config
|
||||||
|
|||||||
+3
-3
@@ -59,9 +59,9 @@ mise run dev:restart -- server # Restart a specific service
|
|||||||
## Project Layout
|
## Project Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
conf/ — Configuration templates (app.example.toml, app.dev.toml, app.docker.toml)
|
conf/ — Configuration templates (app.example.toml, app.docker.toml)
|
||||||
devenv/ — Containerized development environment (docker-compose)
|
devenv/ — Dev environment (docker-compose, dev Dockerfiles, app.dev.toml, mcp-build.sh)
|
||||||
docker/ — Docker build & runtime (Dockerfiles, entrypoints)
|
docker/ — Production Docker build & runtime (Dockerfiles, entrypoints)
|
||||||
cmd/ — Go application entry points
|
cmd/ — Go application entry points
|
||||||
internal/ — Go backend core code
|
internal/ — Go backend core code
|
||||||
agent/ — Agent Gateway (Bun/Elysia)
|
agent/ — Agent Gateway (Bun/Elysia)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Memoh configuration template
|
# Memoh configuration template
|
||||||
# Copy to config.toml and adjust values for your environment.
|
# Copy to config.toml and adjust values for your environment.
|
||||||
# For local development, use: cp conf/app.dev.toml config.toml
|
# For local development, use: cp devenv/app.dev.toml config.toml
|
||||||
|
|
||||||
[log]
|
[log]
|
||||||
level = "info"
|
level = "info"
|
||||||
|
|||||||
@@ -1,5 +1,28 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- Stage 1: Assemble MCP image rootfs (runtime deps only, no Go binary) ----
|
||||||
|
FROM alpine:latest AS mcp-rootfs
|
||||||
|
|
||||||
|
RUN apk add --no-cache grep curl bash
|
||||||
|
RUN apk add --no-cache nodejs npm
|
||||||
|
RUN apk add --no-cache python3 && \
|
||||||
|
curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||||
|
ln -sf /root/.local/bin/uv /usr/local/bin/uv && \
|
||||||
|
ln -sf /root/.local/bin/uvx /usr/local/bin/uvx
|
||||||
|
|
||||||
|
COPY cmd/mcp/template /opt/mcp-template
|
||||||
|
|
||||||
|
RUN printf '#!/bin/sh\n\
|
||||||
|
[ -e /app/mcp ] || { mkdir -p /app; [ -f /opt/mcp ] && cp -a /opt/mcp /app/mcp 2>/dev/null || true; }\n\
|
||||||
|
if [ -x /app/mcp ]; then exec /app/mcp "$@"; fi\n\
|
||||||
|
exec /opt/mcp "$@"\n' > /opt/entrypoint.sh && chmod +x /opt/entrypoint.sh
|
||||||
|
|
||||||
|
RUN tar -cf /tmp/rootfs.tar \
|
||||||
|
--exclude='./proc' --exclude='./sys' --exclude='./dev' \
|
||||||
|
--exclude='./tmp' --exclude='./run' \
|
||||||
|
-C / .
|
||||||
|
|
||||||
|
# ---- Stage 2: Dev server image ----
|
||||||
FROM golang:1.25-alpine
|
FROM golang:1.25-alpine
|
||||||
|
|
||||||
WORKDIR /workspace
|
WORKDIR /workspace
|
||||||
@@ -50,7 +73,10 @@ RUN printf '%s\n' \
|
|||||||
' ]' \
|
' ]' \
|
||||||
'}' > /etc/cni/net.d/10-memoh.conflist
|
'}' > /etc/cni/net.d/10-memoh.conflist
|
||||||
|
|
||||||
COPY docker/server-dev-entrypoint.sh /entrypoint.sh
|
# Raw MCP rootfs for mcp-build.sh to package with compiled binary
|
||||||
|
COPY --from=mcp-rootfs /tmp/rootfs.tar /opt/images/memoh-mcp-rootfs.tar
|
||||||
|
|
||||||
|
COPY devenv/server-entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
VOLUME ["/var/lib/containerd", "/opt/memoh/data"]
|
VOLUME ["/var/lib/containerd", "/opt/memoh/data"]
|
||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
deps:
|
deps:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: docker/Dockerfile.web.dev
|
dockerfile: devenv/Dockerfile.web
|
||||||
container_name: memoh-dev-deps
|
container_name: memoh-dev-deps
|
||||||
working_dir: /workspace
|
working_dir: /workspace
|
||||||
command: ["pnpm", "install"]
|
command: ["pnpm", "install"]
|
||||||
@@ -50,13 +50,13 @@ services:
|
|||||||
migrate:
|
migrate:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: docker/Dockerfile.server.dev
|
dockerfile: devenv/Dockerfile.server
|
||||||
container_name: memoh-dev-migrate
|
container_name: memoh-dev-migrate
|
||||||
working_dir: /workspace
|
working_dir: /workspace
|
||||||
entrypoint: []
|
entrypoint: []
|
||||||
command: ["go", "run", "./cmd/agent/main.go", "migrate", "up"]
|
command: ["go", "run", "./cmd/agent/main.go", "migrate", "up"]
|
||||||
environment:
|
environment:
|
||||||
CONFIG_PATH: /workspace/conf/app.dev.toml
|
CONFIG_PATH: /workspace/devenv/app.dev.toml
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/workspace
|
- ..:/workspace
|
||||||
- go_mod_cache:/go/pkg/mod
|
- go_mod_cache:/go/pkg/mod
|
||||||
@@ -71,14 +71,14 @@ services:
|
|||||||
server:
|
server:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: docker/Dockerfile.server.dev
|
dockerfile: devenv/Dockerfile.server
|
||||||
container_name: memoh-dev-server
|
container_name: memoh-dev-server
|
||||||
working_dir: /workspace
|
working_dir: /workspace
|
||||||
privileged: true
|
privileged: true
|
||||||
pid: host
|
pid: host
|
||||||
command: ["air", "-c", ".air.toml"]
|
command: ["air", "-c", ".air.toml"]
|
||||||
environment:
|
environment:
|
||||||
CONFIG_PATH: /workspace/conf/app.dev.toml
|
CONFIG_PATH: /workspace/devenv/app.dev.toml
|
||||||
volumes:
|
volumes:
|
||||||
- ..:/workspace
|
- ..:/workspace
|
||||||
- go_mod_cache:/go/pkg/mod
|
- go_mod_cache:/go/pkg/mod
|
||||||
@@ -121,7 +121,7 @@ services:
|
|||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
context: ..
|
context: ..
|
||||||
dockerfile: docker/Dockerfile.web.dev
|
dockerfile: devenv/Dockerfile.web
|
||||||
container_name: memoh-dev-web
|
container_name: memoh-dev-web
|
||||||
working_dir: /workspace/packages/web
|
working_dir: /workspace/packages/web
|
||||||
command: ["pnpm", "dev"]
|
command: ["pnpm", "dev"]
|
||||||
|
|||||||
Executable
+67
@@ -0,0 +1,67 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Build MCP binary, package as containerd image, and import.
|
||||||
|
# Called by air after server build — safe to skip outside dev container.
|
||||||
|
set -e
|
||||||
|
|
||||||
|
MCP_IMAGE="${MCP_IMAGE:-docker.io/memohai/mcp:latest}"
|
||||||
|
MCP_BINARY="/opt/memoh/data/.dev/mcp"
|
||||||
|
BASE_ROOTFS="/opt/images/memoh-mcp-rootfs.tar"
|
||||||
|
|
||||||
|
[ -f "$BASE_ROOTFS" ] || exit 0
|
||||||
|
command -v ctr >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$MCP_BINARY")"
|
||||||
|
|
||||||
|
OLD_HASH=$(sha256sum "$MCP_BINARY" 2>/dev/null | cut -d' ' -f1)
|
||||||
|
go build -o "$MCP_BINARY" ./cmd/mcp || exit 0
|
||||||
|
NEW_HASH=$(sha256sum "$MCP_BINARY" | cut -d' ' -f1)
|
||||||
|
|
||||||
|
[ "$OLD_HASH" = "$NEW_HASH" ] && exit 0
|
||||||
|
|
||||||
|
echo "[mcp-dev] Binary changed, rebuilding MCP image..."
|
||||||
|
|
||||||
|
WORK=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$WORK"' EXIT
|
||||||
|
|
||||||
|
# Layer 1: base rootfs (symlink to avoid copying the large file)
|
||||||
|
LAYER1_SHA=$(sha256sum "$BASE_ROOTFS" | cut -d' ' -f1)
|
||||||
|
mkdir -p "$WORK/$LAYER1_SHA"
|
||||||
|
ln -s "$BASE_ROOTFS" "$WORK/$LAYER1_SHA/layer.tar"
|
||||||
|
|
||||||
|
# Layer 2: compiled binary overlay
|
||||||
|
mkdir -p "$WORK/overlay/opt"
|
||||||
|
cp "$MCP_BINARY" "$WORK/overlay/opt/mcp"
|
||||||
|
chmod +x "$WORK/overlay/opt/mcp"
|
||||||
|
tar -cf "$WORK/layer2.tar" -C "$WORK/overlay" opt
|
||||||
|
LAYER2_SHA=$(sha256sum "$WORK/layer2.tar" | cut -d' ' -f1)
|
||||||
|
mkdir -p "$WORK/$LAYER2_SHA"
|
||||||
|
mv "$WORK/layer2.tar" "$WORK/$LAYER2_SHA/layer.tar"
|
||||||
|
|
||||||
|
# OCI image config
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
case "$ARCH" in aarch64|arm64) ARCH="arm64" ;; x86_64|amd64) ARCH="amd64" ;; esac
|
||||||
|
|
||||||
|
printf '{"architecture":"%s","os":"linux","created":"1970-01-01T00:00:00Z","config":{"Entrypoint":["/opt/entrypoint.sh"],"WorkingDir":"/app","Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]},"rootfs":{"type":"layers","diff_ids":["sha256:%s","sha256:%s"]},"history":[{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp rootfs"},{"created":"1970-01-01T00:00:00Z","comment":"memoh-mcp binary"}]}' \
|
||||||
|
"$ARCH" "$LAYER1_SHA" "$LAYER2_SHA" > "$WORK/config.json"
|
||||||
|
|
||||||
|
CONFIG_SHA=$(sha256sum "$WORK/config.json" | cut -d' ' -f1)
|
||||||
|
mv "$WORK/config.json" "$WORK/$CONFIG_SHA.json"
|
||||||
|
|
||||||
|
printf '[{"Config":"%s.json","RepoTags":["%s"],"Layers":["%s/layer.tar","%s/layer.tar"]}]' \
|
||||||
|
"$CONFIG_SHA" "$MCP_IMAGE" "$LAYER1_SHA" "$LAYER2_SHA" > "$WORK/manifest.json"
|
||||||
|
|
||||||
|
# -h follows symlinks (layer 1 is symlinked to avoid copying)
|
||||||
|
tar -chf "$WORK/memoh-mcp.tar" -C "$WORK" manifest.json "$CONFIG_SHA.json" "$LAYER1_SHA/" "$LAYER2_SHA/"
|
||||||
|
|
||||||
|
# Replace image in containerd
|
||||||
|
ctr -n default images rm "$MCP_IMAGE" 2>/dev/null || true
|
||||||
|
ctr -n default images import --all-platforms "$WORK/memoh-mcp.tar" 2>&1 || true
|
||||||
|
|
||||||
|
# Clean old MCP containers so they recreate with new image
|
||||||
|
for c in $(ctr -n default containers ls -q 2>/dev/null | grep "^mcp-"); do
|
||||||
|
ctr -n default tasks kill "$c" 2>/dev/null || true
|
||||||
|
ctr -n default tasks delete "$c" 2>/dev/null || true
|
||||||
|
ctr -n default containers delete "$c" 2>/dev/null || true
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "[mcp-dev] Done. Containers will auto-recreate with new image."
|
||||||
@@ -28,8 +28,14 @@ if ! ctr version >/dev/null 2>&1; then
|
|||||||
echo "ERROR: containerd not responsive after 30s"
|
echo "ERROR: containerd not responsive after 30s"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
echo "containerd is running (pid $CONTAINERD_PID)"
|
||||||
|
|
||||||
echo "containerd is ready, starting server command..."
|
# Build MCP binary and import as containerd image
|
||||||
|
echo "Building MCP image..."
|
||||||
|
(cd /workspace && sh devenv/mcp-build.sh)
|
||||||
|
echo "MCP image ready."
|
||||||
|
|
||||||
|
echo "Starting server..."
|
||||||
|
|
||||||
trap 'kill ${SERVER_PID:-0} 2>/dev/null || true; kill ${CONTAINERD_PID:-0} 2>/dev/null || true; wait' TERM INT
|
trap 'kill ${SERVER_PID:-0} 2>/dev/null || true; kill ${CONTAINERD_PID:-0} 2>/dev/null || true; wait' TERM INT
|
||||||
|
|
||||||
@@ -91,6 +91,10 @@ func (m *Manager) lockContainer(containerID string) func() {
|
|||||||
func (m *Manager) Init(ctx context.Context) error {
|
func (m *Manager) Init(ctx context.Context) error {
|
||||||
image := m.imageRef()
|
image := m.imageRef()
|
||||||
|
|
||||||
|
if _, err := m.service.GetImage(ctx, image); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
_, err := m.service.PullImage(ctx, image, &ctr.PullImageOptions{
|
_, err := m.service.PullImage(ctx, image, &ctr.PullImageOptions{
|
||||||
Unpack: true,
|
Unpack: true,
|
||||||
Snapshotter: m.cfg.Snapshotter,
|
Snapshotter: m.cfg.Snapshotter,
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ description = "Start development environment"
|
|||||||
run = """
|
run = """
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
cp conf/app.dev.toml config.toml
|
cp devenv/app.dev.toml config.toml
|
||||||
docker compose -f devenv/docker-compose.yml up --build
|
docker compose -f devenv/docker-compose.yml up --build
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -69,6 +69,15 @@ run = "docker compose -f devenv/docker-compose.yml logs -f"
|
|||||||
description = "Restart a service (usage: mise run dev:restart -- server)"
|
description = "Restart a service (usage: mise run dev:restart -- server)"
|
||||||
run = "docker compose -f devenv/docker-compose.yml restart $@"
|
run = "docker compose -f devenv/docker-compose.yml restart $@"
|
||||||
|
|
||||||
|
[tasks."mcp:build"]
|
||||||
|
description = "Manually build MCP dev binary (normally auto-triggered by air)"
|
||||||
|
run = """
|
||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
docker compose -f devenv/docker-compose.yml exec server \
|
||||||
|
sh -c 'cd /workspace && sh devenv/mcp-build.sh'
|
||||||
|
"""
|
||||||
|
|
||||||
[tasks.db-up]
|
[tasks.db-up]
|
||||||
description = "Initialize and Migrate Database"
|
description = "Initialize and Migrate Database"
|
||||||
run = "scripts/db-up.sh"
|
run = "scripts/db-up.sh"
|
||||||
@@ -116,6 +125,6 @@ depends = [
|
|||||||
run = """
|
run = """
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
cp conf/app.dev.toml config.toml
|
cp devenv/app.dev.toml config.toml
|
||||||
echo '✓ Setup complete! Run: mise run dev'
|
echo '✓ Setup complete! Run: mise run dev'
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user