fix(deploy): many docker compose bug

This commit is contained in:
Ran
2026-02-12 08:23:25 +08:00
parent 35ce7d169d
commit 01cb6c85db
16 changed files with 228 additions and 90 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ host = "postgres"
password = "your_secure_password" # Must change in config.toml password = "your_secure_password" # Must change in config.toml
[containerd] [containerd]
socket_path = "unix:///var/run/docker.sock" # Use host Docker socket_path = "/run/containerd/containerd.sock"
[qdrant] [qdrant]
base_url = "http://qdrant:6334" base_url = "http://qdrant:6334"
+1
View File
@@ -4,6 +4,7 @@ import { parse } from 'toml'
type AgentGatewayConfig = { type AgentGatewayConfig = {
'agent_gateway': { 'agent_gateway': {
host?: string host?: string
server_addr?: string
port?: number port?: number
}, },
'server': { 'server': {
+27 -11
View File
@@ -3,7 +3,6 @@ import { chatModule } from './modules/chat'
import { corsMiddleware } from './middlewares/cors' import { corsMiddleware } from './middlewares/cors'
import { errorMiddleware } from './middlewares/error' import { errorMiddleware } from './middlewares/error'
import { loadConfig } from './config' import { loadConfig } from './config'
import { join } from 'path'
const config = loadConfig('../config.toml') const config = loadConfig('../config.toml')
@@ -15,17 +14,26 @@ export const getBraveConfig = () => {
} }
export const getBaseUrl = () => { export const getBaseUrl = () => {
let baseUrl = '' const rawAddr =
if (!baseUrl) { typeof config.agent_gateway.server_addr === 'string'
baseUrl = 'http://127.0.0.1' ? config.agent_gateway.server_addr.trim()
: typeof config.server.addr === 'string'
? config.server.addr.trim()
: ''
if (!rawAddr) {
return 'http://127.0.0.1'
} }
if (
typeof config.server.addr === 'string' && if (rawAddr.startsWith('http://') || rawAddr.startsWith('https://')) {
config.server.addr.startsWith(':') return rawAddr.replace(/\/+$/, '')
) {
baseUrl = `http://127.0.0.1${config.server.addr}`
} }
return baseUrl
if (rawAddr.startsWith(':')) {
return `http://127.0.0.1${rawAddr}`
}
return `http://${rawAddr}`
} }
export type AuthFetcher = ( export type AuthFetcher = (
@@ -40,7 +48,12 @@ export const createAuthFetcher = (bearer: string | undefined): AuthFetcher => {
headers.set('Authorization', `Bearer ${bearer}`) headers.set('Authorization', `Bearer ${bearer}`)
} }
return await fetch(join(getBaseUrl(), url), { const requestUrl = new URL(
url,
`${getBaseUrl().replace(/\/+$/, '')}/`,
).toString()
return await fetch(requestUrl, {
...requestOptions, ...requestOptions,
headers, headers,
}) })
@@ -50,6 +63,9 @@ export const createAuthFetcher = (bearer: string | undefined): AuthFetcher => {
const app = new Elysia() const app = new Elysia()
.use(corsMiddleware) .use(corsMiddleware)
.use(errorMiddleware) .use(errorMiddleware)
.get('/health', () => ({
status: 'ok',
}))
.use(chatModule) .use(chatModule)
.listen({ .listen({
port: config.agent_gateway.port ?? 8081, port: config.agent_gateway.port ?? 8081,
+1
View File
@@ -50,6 +50,7 @@ timeout_seconds = 10
[agent_gateway] [agent_gateway]
host = "127.0.0.1" host = "127.0.0.1"
port = 8081 port = 8081
server_addr = ":8080"
[brave] [brave]
api_key = "" api_key = ""
+32 -5
View File
@@ -44,12 +44,39 @@ if [ ! -f config.toml ]; then
echo "" echo ""
fi fi
# Build MCP image # Prepare data root path for host/containerd compatibility
echo -e "${GREEN}Building MCP image...${NC}" MEMOH_DATA_ROOT="$(pwd)/.data/memoh"
if docker build -f docker/Dockerfile.mcp -t memoh-mcp:latest . > /dev/null 2>&1; then mkdir -p "${MEMOH_DATA_ROOT}"
echo -e "${GREEN}✓ MCP image built successfully${NC}" export MEMOH_DATA_ROOT
if grep -q '^data_root[[:space:]]*=' config.toml; then
awk -v path="${MEMOH_DATA_ROOT}" '
$0 ~ /^data_root[[:space:]]*=/ { print "data_root = \"" path "\""; next }
{ print }
' config.toml > config.toml.tmp && mv config.toml.tmp config.toml
fi
echo -e "${GREEN}✓ Data root: ${MEMOH_DATA_ROOT}${NC}"
echo ""
# Prepare container runtime environment
echo -e "${GREEN}Preparing container runtime environment...${NC}"
if sh scripts/containerd-install.sh > /dev/null 2>&1; then
echo -e "${GREEN}✓ Container runtime environment is ready${NC}"
else else
echo -e "${YELLOW}MCP image build failed, will try to pull at runtime${NC}" echo -e "${YELLOW}Failed to prepare container runtime environment, MCP build may be skipped${NC}"
fi
echo ""
# Build MCP image on host with nerdctl
MCP_IMAGE="docker.io/library/memoh-mcp:latest"
echo -e "${GREEN}Building MCP image on host with nerdctl...${NC}"
if command -v nerdctl &> /dev/null && command -v buildctl &> /dev/null && command -v buildkitd &> /dev/null; then
if nerdctl build -f docker/Dockerfile.mcp -t "${MCP_IMAGE}" . > /dev/null 2>&1; then
echo -e "${GREEN}✓ MCP image built successfully (on host)${NC}"
else
echo -e "${YELLOW}⚠ MCP image build failed on host, will try to pull at runtime${NC}"
fi
else
echo -e "${YELLOW}⚠ nerdctl/buildkit environment not found on host, skipping MCP build${NC}"
fi fi
echo "" echo ""
+16 -18
View File
@@ -1,5 +1,5 @@
name: "memoh"
services: services:
postgres: postgres:
image: postgres:18.1-alpine image: postgres:18.1-alpine
container_name: memoh-postgres container_name: memoh-postgres
@@ -8,7 +8,7 @@ services:
POSTGRES_USER: memoh POSTGRES_USER: memoh
POSTGRES_PASSWORD: memoh123 POSTGRES_PASSWORD: memoh123
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql
- ./db/migrations:/docker-entrypoint-initdb.d:ro - ./db/migrations:/docker-entrypoint-initdb.d:ro
expose: expose:
- "5432" - "5432"
@@ -38,26 +38,24 @@ services:
networks: networks:
- memoh-network - memoh-network
docker-cli:
image: docker:27-cli
container_name: memoh-docker-cli
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- memoh_bot_data:/var/lib/memoh/data
command: ["tail", "-f", "/dev/null"]
restart: unless-stopped
networks:
- memoh-network
server: server:
build: build:
context: ./docker context: .
dockerfile: Dockerfile.server dockerfile: docker/Dockerfile.server
container_name: memoh-server container_name: memoh-server
pid: host
volumes: volumes:
- ./config.toml:/app/config.toml:ro - ./config.toml:/app/config.toml:ro
- /var/run/docker.sock:/var/run/docker.sock - /run/containerd/containerd.sock:/run/containerd/containerd.sock
- memoh_bot_data:/var/lib/memoh/data - /var/lib/containerd:/var/lib/containerd
- server_cni_state:/var/lib/cni
- ${MEMOH_DATA_ROOT:-/opt/memoh/data}:${MEMOH_DATA_ROOT:-/opt/memoh/data}
cap_add:
- SYS_ADMIN
- NET_ADMIN
security_opt:
- seccomp:unconfined
- apparmor:unconfined
ports: ports:
- "8080:8080" - "8080:8080"
depends_on: depends_on:
@@ -106,7 +104,7 @@ volumes:
driver: local driver: local
qdrant_data: qdrant_data:
driver: local driver: local
memoh_bot_data: server_cni_state:
driver: local driver: local
networks: networks:
+34 -2
View File
@@ -17,11 +17,43 @@ FROM alpine:latest
WORKDIR /app WORKDIR /app
RUN apk add --no-cache ca-certificates tzdata wget RUN apk add --no-cache ca-certificates tzdata wget nerdctl cni-plugins iptables \
&& mkdir -p /opt/cni/bin \
&& (cp -a /usr/lib/cni/. /opt/cni/bin/ 2>/dev/null || true) \
&& (cp -a /usr/libexec/cni/. /opt/cni/bin/ 2>/dev/null || true) \
&& mkdir -p /etc/cni/net.d /var/lib/cni \
&& printf '%s\n' \
'{' \
' "cniVersion": "1.0.0",' \
' "name": "memoh-cni",' \
' "plugins": [' \
' {' \
' "type": "bridge",' \
' "bridge": "cni0",' \
' "isGateway": true,' \
' "ipMasq": true,' \
' "promiscMode": true,' \
' "ipam": {' \
' "type": "host-local",' \
' "ranges": [[' \
' { "subnet": "10.88.0.0/16" }' \
' ]],' \
' "routes": [' \
' { "dst": "0.0.0.0/0" }' \
' ]' \
' }' \
' },' \
' {' \
' "type": "portmap",' \
' "capabilities": { "portMappings": true }' \
' }' \
' ]' \
'}' > /etc/cni/net.d/10-memoh.conflist
COPY --from=builder /build/memoh-server /app/memoh-server COPY --from=builder /build/memoh-server /app/memoh-server
COPY --from=builder /build/spec /app/spec
RUN mkdir -p /var/lib/memoh/data RUN mkdir -p /opt/memoh/data
EXPOSE 8080 EXPOSE 8080
+1 -1
View File
@@ -28,6 +28,6 @@ COPY docker/config/nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80 EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 CMD wget --no-verbose --tries=1 --spider http://localhost/health || exit 1
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]
+5 -4
View File
@@ -4,7 +4,7 @@ level = "info"
format = "text" format = "text"
[server] [server]
addr = ":8080" addr = "server:8080"
## Admin ## Admin
[admin] [admin]
@@ -19,13 +19,13 @@ jwt_expires_in = "168h"
## Docker configuration ## Docker configuration
[containerd] [containerd]
socket_path = "unix:///var/run/docker.sock" socket_path = "/run/containerd/containerd.sock"
namespace = "default" namespace = "default"
[mcp] [mcp]
busybox_image = "memoh-mcp:latest" busybox_image = "docker.io/library/memoh-mcp:latest"
snapshotter = "overlayfs" snapshotter = "overlayfs"
data_root = "/var/lib/memoh/data" data_root = "/opt/memoh/data"
data_mount = "/data" data_mount = "/data"
## Postgres configuration ## Postgres configuration
@@ -48,6 +48,7 @@ timeout_seconds = 10
[agent_gateway] [agent_gateway]
host = "agent" host = "agent"
port = 8081 port = 8081
server_addr = "server:8080"
[brave] [brave]
api_key = "" api_key = ""
+27 -27
View File
@@ -1,5 +1,6 @@
server { server {
listen 80; listen 80;
listen [::]:80;
server_name _; server_name _;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
@@ -15,40 +16,39 @@ server {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
# API 代理 # Nginx 健康检查
location = /health {
access_log off;
add_header Content-Type text/plain;
return 200 "ok\n";
}
# 统一代理参数
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Swagger 文档(保留 /api 前缀)
location ~ ^/api/(docs(?:/.*)?|swagger\.json)$ {
proxy_pass http://memoh-server:8080;
}
# API 代理(其余 /api/* 去掉 /api 前缀转发)
location /api/ { location /api/ {
proxy_pass http://memoh-server:8080/; proxy_pass http://memoh-server:8080/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
} }
# Agent Gateway 代理 # Agent Gateway 代理
location /agent/ { location /agent/ {
proxy_pass http://memoh-agent:8081/; proxy_pass http://memoh-agent:8081/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
} }
# 静态资源缓存 # 静态资源缓存
+23 -10
View File
@@ -9,7 +9,6 @@ import (
"io" "io"
"log/slog" "log/slog"
"os" "os"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
"syscall" "syscall"
@@ -673,15 +672,8 @@ func (s *DefaultService) ExecTaskStreaming(ctx context.Context, containerID stri
if req.Terminal { if req.Terminal {
ioOpts = append(ioOpts, cio.WithTerminal) ioOpts = append(ioOpts, cio.WithTerminal)
} }
fifoDir := strings.TrimSpace(req.FIFODir) fifoDir, err := resolveExecFIFODir(req.FIFODir)
if fifoDir == "" { if err != nil {
if homeDir, err := os.UserHomeDir(); err == nil && homeDir != "" {
fifoDir = filepath.Join(homeDir, ".memoh", "containerd-fifo")
} else {
fifoDir = "/tmp/memoh-containerd-fifo"
}
}
if err := os.MkdirAll(fifoDir, 0o755); err != nil {
_ = stdinR.Close() _ = stdinR.Close()
_ = stdinW.Close() _ = stdinW.Close()
_ = stdoutR.Close() _ = stdoutR.Close()
@@ -752,6 +744,27 @@ func (s *DefaultService) ExecTaskStreaming(ctx context.Context, containerID stri
}, nil }, nil
} }
func resolveExecFIFODir(preferred string) (string, error) {
candidates := make([]string, 0, 3)
if p := strings.TrimSpace(preferred); p != "" {
candidates = append(candidates, p)
}
candidates = append(candidates, "/var/lib/containerd/memoh-fifo", "/tmp/memoh-containerd-fifo")
var lastErr error
for _, dir := range candidates {
if err := os.MkdirAll(dir, 0o755); err == nil {
return dir, nil
} else {
lastErr = err
}
}
if lastErr == nil {
lastErr = fmt.Errorf("no fifo directory candidate available")
}
return "", lastErr
}
func (s *DefaultService) ListContainersByLabel(ctx context.Context, key, value string) ([]containerd.Container, error) { func (s *DefaultService) ListContainersByLabel(ctx context.Context, key, value string) ([]containerd.Container, error) {
if key == "" { if key == "" {
return nil, ErrInvalidArgument return nil, ErrInvalidArgument
+37 -5
View File
@@ -4,11 +4,13 @@ import (
"bufio" "bufio"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"os/exec" "os/exec"
"path/filepath"
"runtime" "runtime"
"strings" "strings"
"sync" "sync"
@@ -188,7 +190,8 @@ func (h *ContainerdHandler) getMCPSession(ctx context.Context, containerID strin
func (h *ContainerdHandler) startContainerdMCPSession(ctx context.Context, containerID string) (*mcpSession, error) { func (h *ContainerdHandler) startContainerdMCPSession(ctx context.Context, containerID string) (*mcpSession, error) {
execSession, err := h.service.ExecTaskStreaming(ctx, containerID, ctr.ExecTaskRequest{ execSession, err := h.service.ExecTaskStreaming(ctx, containerID, ctr.ExecTaskRequest{
Args: []string{"/app/mcp"}, Args: []string{"/app/mcp"},
FIFODir: h.mcpFIFODir(),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -207,11 +210,15 @@ func (h *ContainerdHandler) startContainerdMCPSession(ctx context.Context, conta
go func() { go func() {
_, err := execSession.Wait() _, err := execSession.Wait()
if err != nil { if err != nil {
if isBenignMCPSessionExit(err) {
sess.closeWithError(io.EOF)
return
}
h.logger.Error("mcp session exited", slog.Any("error", err), slog.String("container_id", containerID)) h.logger.Error("mcp session exited", slog.Any("error", err), slog.String("container_id", containerID))
sess.closeWithError(err) sess.closeWithError(err)
} else { return
sess.closeWithError(io.EOF)
} }
sess.closeWithError(io.EOF)
}() }()
return sess, nil return sess, nil
@@ -273,11 +280,15 @@ func (h *ContainerdHandler) startLimaMCPSession(containerID string) (*mcpSession
go sess.readLoop() go sess.readLoop()
go func() { go func() {
if err := cmd.Wait(); err != nil { if err := cmd.Wait(); err != nil {
if isBenignMCPSessionExit(err) {
sess.closeWithError(io.EOF)
return
}
h.logger.Error("mcp session exited", slog.Any("error", err), slog.String("container_id", containerID)) h.logger.Error("mcp session exited", slog.Any("error", err), slog.String("container_id", containerID))
sess.closeWithError(err) sess.closeWithError(err)
} else { return
sess.closeWithError(io.EOF)
} }
sess.closeWithError(io.EOF)
}() }()
return sess, nil return sess, nil
@@ -320,11 +331,32 @@ func (h *ContainerdHandler) startMCPStderrLogger(stderr io.ReadCloser, container
h.logger.Warn("mcp stderr", slog.String("container_id", containerID), slog.String("message", line)) h.logger.Warn("mcp stderr", slog.String("container_id", containerID), slog.String("message", line))
} }
if err := scanner.Err(); err != nil { if err := scanner.Err(); err != nil {
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) || strings.Contains(err.Error(), "closed pipe") {
return
}
h.logger.Error("mcp stderr read failed", slog.Any("error", err), slog.String("container_id", containerID)) h.logger.Error("mcp stderr read failed", slog.Any("error", err), slog.String("container_id", containerID))
} }
}() }()
} }
func isBenignMCPSessionExit(err error) bool {
if err == nil {
return false
}
if errors.Is(err, context.Canceled) || errors.Is(err, io.EOF) || errors.Is(err, io.ErrClosedPipe) {
return true
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "code = canceled") || strings.Contains(msg, "context canceled") || strings.Contains(msg, "closed pipe")
}
func (h *ContainerdHandler) mcpFIFODir() string {
if root := strings.TrimSpace(h.cfg.DataRoot); root != "" {
return filepath.Join(root, ".containerd-fifo")
}
return "/tmp/memoh-containerd-fifo"
}
func (s *mcpSession) readLoop() { func (s *mcpSession) readLoop() {
scanner := bufio.NewScanner(s.stdout) scanner := bufio.NewScanner(s.stdout)
scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024)
+13 -4
View File
@@ -178,6 +178,7 @@ func (h *ContainerdHandler) startContainerdMCPCommandSession(ctx context.Context
Args: args, Args: args,
Env: env, Env: env,
WorkDir: strings.TrimSpace(req.Cwd), WorkDir: strings.TrimSpace(req.Cwd),
FIFODir: h.mcpFIFODir(),
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -195,11 +196,15 @@ func (h *ContainerdHandler) startContainerdMCPCommandSession(ctx context.Context
go func() { go func() {
_, err := execSession.Wait() _, err := execSession.Wait()
if err != nil { if err != nil {
if isBenignMCPSessionExit(err) {
sess.closeWithError(io.EOF)
return
}
h.logger.Error("mcp stdio session exited", slog.Any("error", err), slog.String("container_id", containerID)) h.logger.Error("mcp stdio session exited", slog.Any("error", err), slog.String("container_id", containerID))
sess.closeWithError(err) sess.closeWithError(err)
} else { return
sess.closeWithError(io.EOF)
} }
sess.closeWithError(io.EOF)
}() }()
return sess, nil return sess, nil
} }
@@ -342,11 +347,15 @@ func (h *ContainerdHandler) startLimaMCPCommandSession(containerID string, req M
go sess.readLoop() go sess.readLoop()
go func() { go func() {
if err := cmd.Wait(); err != nil { if err := cmd.Wait(); err != nil {
if isBenignMCPSessionExit(err) {
sess.closeWithError(io.EOF)
return
}
h.logger.Error("mcp stdio session exited", slog.Any("error", err), slog.String("container_id", containerID)) h.logger.Error("mcp stdio session exited", slog.Any("error", err), slog.String("container_id", containerID))
sess.closeWithError(err) sess.closeWithError(err)
} else { return
sess.closeWithError(io.EOF)
} }
sess.closeWithError(io.EOF)
}() }()
return sess, nil return sess, nil
+5
View File
@@ -17,6 +17,7 @@ func NewPingHandler(log *slog.Logger) *PingHandler {
func (h *PingHandler) Register(e *echo.Echo) { func (h *PingHandler) Register(e *echo.Echo) {
e.GET("/ping", h.Ping) e.GET("/ping", h.Ping)
e.HEAD("/health", h.PingHead)
} }
func (h *PingHandler) Ping(c echo.Context) error { func (h *PingHandler) Ping(c echo.Context) error {
@@ -24,3 +25,7 @@ func (h *PingHandler) Ping(c echo.Context) error {
"status": "ok", "status": "ok",
}) })
} }
func (h *PingHandler) PingHead(c echo.Context) error {
return c.NoContent(http.StatusOK)
}
+1 -1
View File
@@ -42,7 +42,7 @@ func NewServer(log *slog.Logger, addr string, jwtSecret string, pingHandler *han
})) }))
e.Use(auth.JWTMiddleware(jwtSecret, func(c echo.Context) bool { e.Use(auth.JWTMiddleware(jwtSecret, func(c echo.Context) bool {
path := c.Request().URL.Path path := c.Request().URL.Path
if path == "/ping" || path == "/api/swagger.json" || path == "/auth/login" { if path == "/ping" || path == "/health" || path == "/api/swagger.json" || path == "/auth/login" {
return true return true
} }
if strings.HasPrefix(path, "/api/docs") { if strings.HasPrefix(path, "/api/docs") {
+4 -1
View File
@@ -7,7 +7,10 @@ if [ "$(uname -s)" = "Darwin" ]; then
exit $? exit $?
fi fi
if command -v containerd >/dev/null 2>&1 && command -v nerdctl >/dev/null 2>&1 && command -v buildctl >/dev/null 2>&1 && command -v buildkitd >/dev/null 2>&1; then if command -v containerd >/dev/null 2>&1 \
&& command -v nerdctl >/dev/null 2>&1 \
&& command -v buildctl >/dev/null 2>&1 \
&& command -v buildkitd >/dev/null 2>&1; then
containerd --version containerd --version
nerdctl --version nerdctl --version
buildctl --version buildctl --version