Skip to content

Commit d74415e

Browse files
ANIIAN91root
andauthored
Convert ccNexus to headless Docker service (#61)
Co-authored-by: root <root@ser960574800116.local>
1 parent 1c89391 commit d74415e

File tree

5 files changed

+277
-0
lines changed

5 files changed

+277
-0
lines changed

Dockerfile

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# Build stage
2+
FROM golang:1.24-alpine AS builder
3+
4+
# Install build dependencies
5+
RUN apk add --no-cache gcc musl-dev sqlite-dev
6+
7+
WORKDIR /app
8+
9+
# Copy module definition first for caching
10+
COPY app/go.mod ./
11+
12+
# Download dependencies (go.sum will be generated after tidy)
13+
RUN go mod download
14+
15+
# Copy source code
16+
COPY app/ ./
17+
18+
# Ensure module graph is complete (generates go.sum)
19+
RUN go mod tidy
20+
21+
# Build the headless API server
22+
RUN CGO_ENABLED=1 GOOS=linux go build -ldflags="-s -w" -o ccnexus-server ./cmd/server
23+
24+
# Runtime stage
25+
FROM alpine:3.19
26+
27+
# Install runtime dependencies
28+
RUN apk add --no-cache ca-certificates sqlite-libs tzdata wget
29+
30+
WORKDIR /app
31+
32+
# Prepare data directory
33+
RUN mkdir -p /data
34+
35+
# Copy binary from builder
36+
COPY --from=builder /app/ccnexus-server /app/ccnexus-server
37+
38+
# Environment variables
39+
ENV CCNEXUS_DATA_DIR=/data
40+
ENV CCNEXUS_PORT=3000
41+
ENV CCNEXUS_DB_PATH=/data/ccnexus.db
42+
43+
# Expose HTTP API port
44+
EXPOSE 3000
45+
46+
# Health check targets the API health endpoint
47+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
48+
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1
49+
50+
# Volume for persistent data
51+
VOLUME ["/data"]
52+
53+
# Run the headless server
54+
ENTRYPOINT ["/app/ccnexus-server"]

README_DOCKER.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
## Headless Docker Service Summary
2+
3+
本次调整将 ccNexus 从 Wails 桌面应用改造为纯后端 HTTP 服务,并提供容器化运行方式。核心改动要点:
4+
5+
1. 新增无头入口
6+
- 新增 [app/cmd/server/main.go](app/cmd/server/main.go) 作为 headless 入口:仅启动 HTTP 代理(无 GUI),支持优雅退出,读取 `CCNEXUS_DATA_DIR`、`CCNEXUS_DB_PATH`、`CCNEXUS_PORT`、`CCNEXUS_LOG_LEVEL` 环境变量。
7+
- 若存储中无任何 endpoint,会自动写入默认示例 endpoint,避免 “no endpoints configured” 直接退出。请尽快替换为真实 API 配置。
8+
9+
2. 镜像与构建
10+
- [Dockerfile](Dockerfile) 仅构建后端二进制 `ccnexus-server`,移除前端构建。暴露端口仅 `3000`(HTTP API)。
11+
- 构建阶段执行 `go mod tidy` 以生成 `go.sum`,并启用 CGO 支持 SQLite。
12+
13+
3. 运行与编排
14+
- [docker-compose.yml](docker-compose.yml) 仅映射 API 端口(示例 `3021:3000`),挂载数据卷 `/data`,健康检查指向 `/health`。
15+
- 默认环境:`CCNEXUS_DATA_DIR=/data`,`CCNEXUS_DB_PATH=/data/ccnexus.db`,`CCNEXUS_PORT=3000`。
16+
17+
4. 数据与迁移
18+
- 仍支持从旧的 JSON 配置迁移到 SQLite(路径位于数据目录)。
19+
- 将主机目录挂载到 `/data` 可持久化数据库与配置。
20+
21+
5. 使用快速指引
22+
- 端口占用时可改成 `HOST_PORT:3000`(例如 `3021:3000`)。
23+
- 构建运行:`docker compose up -d --build`。
24+
- 启动后更新数据库中的 endpoint key/model 到真实值,或通过配置文件/环境变量完成覆盖。
25+
26+
此版本专注于 API 代理,无任何桌面/前端界面。

app/cmd/server/main.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
"os"
7+
"os/signal"
8+
"path/filepath"
9+
"strconv"
10+
"syscall"
11+
12+
"github.com/lich0821/ccNexus/internal/config"
13+
"github.com/lich0821/ccNexus/internal/logger"
14+
"github.com/lich0821/ccNexus/internal/proxy"
15+
"github.com/lich0821/ccNexus/internal/storage"
16+
)
17+
18+
func main() {
19+
dataDir := resolveDataDir()
20+
if err := os.MkdirAll(dataDir, 0755); err != nil {
21+
logger.Error("Failed to create data dir %s: %v", dataDir, err)
22+
os.Exit(1)
23+
}
24+
25+
configPath := filepath.Join(dataDir, "config.json")
26+
statsPath := filepath.Join(dataDir, "stats.json")
27+
dbPath := os.Getenv("CCNEXUS_DB_PATH")
28+
if dbPath == "" {
29+
dbPath = filepath.Join(dataDir, "ccnexus.db")
30+
}
31+
32+
if err := storage.MigrateFromJSON(configPath, statsPath, dbPath); err != nil {
33+
logger.Error("Migration failed: %v", err)
34+
os.Exit(1)
35+
}
36+
37+
sqliteStorage, err := storage.NewSQLiteStorage(dbPath)
38+
if err != nil {
39+
logger.Error("Failed to open SQLite storage: %v", err)
40+
os.Exit(1)
41+
}
42+
defer sqliteStorage.Close()
43+
44+
cfg, err := loadConfig(sqliteStorage)
45+
if err != nil {
46+
logger.Error("Unable to load configuration: %v", err)
47+
os.Exit(1)
48+
}
49+
50+
applyEnvOverrides(cfg)
51+
setLogLevels(cfg.GetLogLevel())
52+
53+
if err := cfg.Validate(); err != nil {
54+
logger.Error("Invalid configuration: %v", err)
55+
os.Exit(1)
56+
}
57+
58+
deviceID, err := sqliteStorage.GetOrCreateDeviceID()
59+
if err != nil {
60+
logger.Warn("Failed to get device ID: %v, using default", err)
61+
deviceID = "default"
62+
}
63+
64+
statsAdapter := storage.NewStatsStorageAdapter(sqliteStorage)
65+
p := proxy.New(cfg, statsAdapter, deviceID)
66+
67+
errCh := make(chan error, 1)
68+
go func() {
69+
errCh <- p.Start()
70+
}()
71+
72+
logger.Info("ccNexus headless API listening on :%d (data dir: %s, db: %s)", cfg.GetPort(), dataDir, dbPath)
73+
74+
sigCh := make(chan os.Signal, 1)
75+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
76+
77+
select {
78+
case sig := <-sigCh:
79+
logger.Info("Received signal %s, shutting down", sig.String())
80+
if err := p.Stop(); err != nil {
81+
logger.Warn("Graceful shutdown failed: %v", err)
82+
}
83+
case err := <-errCh:
84+
if err != nil && !errors.Is(err, http.ErrServerClosed) {
85+
logger.Error("Proxy server stopped with error: %v", err)
86+
os.Exit(1)
87+
}
88+
}
89+
90+
logger.Info("ccNexus stopped")
91+
}
92+
93+
func resolveDataDir() string {
94+
if dir := os.Getenv("CCNEXUS_DATA_DIR"); dir != "" {
95+
return dir
96+
}
97+
if home, err := os.UserHomeDir(); err == nil {
98+
return filepath.Join(home, ".ccNexus")
99+
}
100+
return "/data"
101+
}
102+
103+
func loadConfig(sqliteStorage *storage.SQLiteStorage) (*config.Config, error) {
104+
adapter := storage.NewConfigStorageAdapter(sqliteStorage)
105+
cfg, err := config.LoadFromStorage(adapter)
106+
if err != nil {
107+
logger.Warn("Failed to load config from storage, using default: %v", err)
108+
cfg = config.DefaultConfig()
109+
if saveErr := cfg.SaveToStorage(adapter); saveErr != nil {
110+
logger.Warn("Failed to persist default config: %v", saveErr)
111+
}
112+
}
113+
114+
// Seed a default endpoint when none are configured to avoid boot failure
115+
if len(cfg.Endpoints) == 0 {
116+
logger.Warn("No endpoints found; seeding a default endpoint")
117+
cfg.Endpoints = config.DefaultConfig().Endpoints
118+
if saveErr := cfg.SaveToStorage(adapter); saveErr != nil {
119+
logger.Warn("Failed to persist seeded endpoint: %v", saveErr)
120+
}
121+
}
122+
return cfg, nil
123+
}
124+
125+
func applyEnvOverrides(cfg *config.Config) {
126+
if portStr := os.Getenv("CCNEXUS_PORT"); portStr != "" {
127+
if port, err := strconv.Atoi(portStr); err == nil {
128+
cfg.UpdatePort(port)
129+
} else {
130+
logger.Warn("Invalid CCNEXUS_PORT value %q: %v", portStr, err)
131+
}
132+
}
133+
134+
if levelStr := os.Getenv("CCNEXUS_LOG_LEVEL"); levelStr != "" {
135+
if level, err := strconv.Atoi(levelStr); err == nil {
136+
cfg.UpdateLogLevel(level)
137+
} else {
138+
logger.Warn("Invalid CCNEXUS_LOG_LEVEL value %q: %v", levelStr, err)
139+
}
140+
}
141+
}
142+
143+
func setLogLevels(level int) {
144+
if level < 0 {
145+
return
146+
}
147+
logger.GetLogger().SetMinLevel(logger.LogLevel(level))
148+
logger.GetLogger().SetConsoleLevel(logger.LogLevel(level))
149+
}

docker-compose.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
version: '3.8'
2+
3+
services:
4+
ccnexus:
5+
build:
6+
context: .
7+
dockerfile: Dockerfile
8+
container_name: ccnexus
9+
restart: unless-stopped
10+
ports:
11+
- "3021:3000"
12+
volumes:
13+
# Persistent data storage
14+
- /data/ccnexus/:/data
15+
environment:
16+
- CCNEXUS_PORT=3000
17+
- CCNEXUS_DATA_DIR=/data
18+
- CCNEXUS_DB_PATH=/data/ccnexus.db
19+
- TZ=Asia/Shanghai
20+
healthcheck:
21+
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
22+
interval: 30s
23+
timeout: 10s
24+
retries: 3
25+
start_period: 10s
26+
# Optional: resource limits
27+
# deploy:
28+
# resources:
29+
# limits:
30+
# cpus: '1'
31+
# memory: 512M
32+
# reservations:
33+
# cpus: '0.25'
34+
# memory: 128M
35+
36+
volumes:
37+
ccnexus-data:
38+
driver: local

docker-entrypoint.sh

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
#!/bin/sh
2+
set -e
3+
4+
# Ensure data directory exists and has correct permissions
5+
if [ ! -w "${CCNEXUS_DATA_DIR:-/data}" ]; then
6+
echo "Warning: Data directory is not writable, attempting to fix..."
7+
fi
8+
9+
# Run the server
10+
exec /app/ccnexus-server "$@"

0 commit comments

Comments
 (0)