Skip to content

Commit fb13afa

Browse files
committed
feat(dev): 开发体验改进与跨平台支持
- Makefile: dev/dev-api 自动检测并启动 PostgreSQL,非 Linux 自动构建网关镜像 - 新增 scripts/dev-backend.sh 后端热重载开发脚本 - entry.go: 能力推导优先读取 image.lock 显式声明,解决重建后 template_image_ref 未同步问题 - sshproxy/resolver: 跨平台容器地址解析(macOS/Windows 用端口映射替代直接 IP) - cloudclaude: 新增 cold_promoter 冷文件晋升逻辑 - web/admin: hosts 列表优先显示进行中任务状态 - runtime: worker 与 runtime_service 适配调整
1 parent 54d91f4 commit fb13afa

14 files changed

Lines changed: 330 additions & 146 deletions

File tree

Makefile

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,25 +7,39 @@ DEV_COMPOSE := docker compose -f deploy/compose/control-plane.dev.yml
77

88
# ── Development ──────────────────────────────────────────────
99

10-
dev: ## Start backend + frontend (requires PostgreSQL running)
10+
dev: ## Start backend + frontend (auto-starts PostgreSQL if needed)
1111
@echo "Starting control-plane + admin frontend..."
1212
@echo " API → http://$(CONTROL_PLANE_ADDR)"
1313
@echo " Web → http://localhost:5173"
1414
@echo " Agent → embedded (in-process)"
1515
@echo ""
1616
@mkdir -p .data
17-
@trap 'kill 0' EXIT; \
18-
HOST_AGENT_MODE=embedded DATA_DIR=$(CURDIR)/.data go run ./cmd/control-plane & \
19-
cd web/admin && pnpm dev & \
17+
@# Auto-start PostgreSQL if not running
18+
@nc -z 127.0.0.1 $(POSTGRES_PORT) > /dev/null 2>&1 || \
19+
{ echo "PostgreSQL not running, starting it now..."; $(MAKE) db; }
20+
@# On macOS / Windows, ensure gateway sidecar image exists (ContainerProxyProvider needs it)
21+
@if [ "$$(uname -s)" != "Linux" ]; then \
22+
if ! docker images $(GATEWAY_IMAGE) --format '{{.Repository}}:{{.Tag}}' 2>/dev/null | grep -qF '$(GATEWAY_IMAGE)'; then \
23+
echo "Non-Linux host: gateway image not found, building $(GATEWAY_IMAGE)..."; \
24+
$(MAKE) gateway-image; \
25+
fi; \
26+
fi
27+
@trap 'kill $$CP_PID $$VITE_PID 2>/dev/null; wait' INT EXIT; \
28+
bash scripts/dev-backend.sh & CP_PID=$$!; \
29+
cd web/admin && pnpm dev & VITE_PID=$$!; \
2030
wait
2131

22-
dev-api: ## Start backend only (with embedded host-agent)
32+
dev-api: ## Start backend only with hot reload (auto-starts PostgreSQL)
2333
@mkdir -p .data
24-
HOST_AGENT_MODE=embedded DATA_DIR=$(CURDIR)/.data go run ./cmd/control-plane
34+
@nc -z 127.0.0.1 $(POSTGRES_PORT) > /dev/null 2>&1 || \
35+
{ echo "PostgreSQL not running, starting it now..."; $(MAKE) db; }
36+
@bash scripts/dev-backend.sh
2537

2638
dev-web: ## Start frontend only
2739
cd web/admin && pnpm dev
2840

41+
dev-all: dev ## Alias for 'make dev' (PostgreSQL is auto-started)
42+
2943
# ── Database ─────────────────────────────────────────────────
3044

3145
db: ## Start PostgreSQL via Docker Compose
@@ -54,13 +68,15 @@ test-smoke: ## Run BATS bootstrap smoke tests
5468
# ── Images ────────────────────────────────────────────────────
5569

5670
MANAGED_USER_TAG := $(shell grep '^image_name:' deploy/docker/managed-user/image.lock | cut -d' ' -f2)
71+
GATEWAY_IMAGE := cloud-cli-proxy-sing-gateway:local
5772

5873
user-image: ## Build managed-user image (cloud desktop)
5974
docker build -t $(MANAGED_USER_TAG) -f deploy/docker/managed-user/Dockerfile .
6075
@echo "Built: $(MANAGED_USER_TAG)"
6176

6277
gateway-image: ## Build sing-box + iptables sidecar (required for macOS/Windows host-agent egress)
63-
docker build -t cloud-cli-proxy-sing-gateway:local -f deploy/docker/sing-box-gateway/Dockerfile .
78+
docker build -t $(GATEWAY_IMAGE) -f deploy/docker/sing-box-gateway/Dockerfile .
79+
@echo "Built: $(GATEWAY_IMAGE)"
6480

6581
# ── Build ────────────────────────────────────────────────────
6682

@@ -117,8 +133,14 @@ release: ## Create and push release tag (usage make release VERSION=1.5.0)
117133
setup: ## First-time setup: install deps, copy .env
118134
cd web/admin && pnpm install
119135
@test -f .env || cp .env.example .env
120-
@echo "Done. Edit .env if needed, then run: make db && make dev"
121-
@echo "提示:在 macOS/Windows 上调试主机出口代理(sidecar 网关)前请先执行一次: make gateway-image"
136+
@echo "Done. Edit .env if needed, then run: make dev"
137+
@echo ""
138+
@echo "常用命令:"
139+
@echo " make dev 一键启动 PostgreSQL + 后端 + 前端(推荐)"
140+
@echo " make dev-api 只启动后端(自动启动 PostgreSQL)"
141+
@echo " make dev-web 只启动前端"
142+
@echo " make db 只启动 PostgreSQL"
143+
@echo " make gateway-image 构建网关 sidecar 镜像(非 Linux 首次需执行)"
122144

123145
# ── Utilities ────────────────────────────────────────────────
124146

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package cloudclaude
2+
3+
import (
4+
"context"
5+
"io"
6+
"sync/atomic"
7+
8+
"golang.org/x/crypto/ssh"
9+
)
10+
11+
// ColdPromoter performs background promotion of cold files to hot.
12+
// Phase 37: Full implementation with inotify-based promotion is planned.
13+
// Currently a minimal stub so mount_strategy.go compiles on all platforms.
14+
type ColdPromoter struct {
15+
connB *ssh.Client
16+
coldRoot string
17+
hotRoot string
18+
logger io.Writer
19+
pidFile string
20+
21+
promotionCount atomic.Int64
22+
promotionBytes atomic.Int64
23+
promotionFailedCount atomic.Int64
24+
}
25+
26+
// NewColdPromoter creates a new ColdPromoter.
27+
func NewColdPromoter(connB *ssh.Client, coldRoot, hotRoot string, logger io.Writer, pidFile string) *ColdPromoter {
28+
return &ColdPromoter{
29+
connB: connB,
30+
coldRoot: coldRoot,
31+
hotRoot: hotRoot,
32+
logger: logger,
33+
pidFile: pidFile,
34+
}
35+
}
36+
37+
// Run starts the promotion loop.
38+
// Stub: Phase 37 will implement inotify watcher + rsync promotion.
39+
func (p *ColdPromoter) Run(ctx context.Context) {
40+
<-ctx.Done()
41+
}
42+
43+
// Wait waits for the promoter to finish.
44+
// Stub: Phase 37 will wait for the background goroutine to exit.
45+
func (p *ColdPromoter) Wait() {}
46+
47+
// Stats returns promotion statistics.
48+
func (p *ColdPromoter) Stats() (count int, bytes int64, failed int) {
49+
return int(p.promotionCount.Load()), p.promotionBytes.Load(), int(p.promotionFailedCount.Load())
50+
}

internal/cloudclaude/ssh.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ func ConnectAndRunClaudeV3(cfg SSHConfig, claudeArgs []string, cwd string,
140140
// 退出码引用 cloudclaude.Exit* 命名常量(避开 v2.0 main.go ExitConfigError=4 /
141141
// ExitInternalError=5 撞码;OAuthNotFound=6 / OAuthExpired=7)。
142142
if mountCfg.ClaudeAccountID == "" {
143-
fmt.Fprintln(mountCfg.Logger, "[!] gateway 未返回 claude_account_id,跳过 OAuth 过期检查(建议升级 gateway 至 v3.0)")
143+
fmt.Fprintln(mountCfg.Logger, "[!] 未配置 Claude Account 绑定,跳过 OAuth 过期检查(不影响正常使用)")
144144
} else {
145145
status, oauthErr := CheckOAuthCredentials(connA, mountCfg.ClaudeAccountID)
146146
if oauthErr != nil {

internal/controlplane/app/app.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,8 @@ func New(ctx context.Context, cfg Config) (*App, error) {
156156
AdminEvents: repo,
157157
EventRecorder: repo,
158158
EntryStore: repo,
159+
EntryBaseURL: "",
160+
ImageLockPath: runtime.DefaultImageLockPath,
159161
UserHosts: repo,
160162
SSHKeys: repo,
161163
})

internal/controlplane/http/entry.go

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/jackc/pgx/v5"
1313
"golang.org/x/crypto/bcrypt"
1414

15+
"github.com/zanel1u/cloud-cli-proxy/internal/runtime"
1516
"github.com/zanel1u/cloud-cli-proxy/internal/store/repository"
1617
)
1718

@@ -34,8 +35,18 @@ const v3CapBaseline = "v3.0.0"
3435
// 1. 整体 trim 空白;
3536
// 2. 取最后一个 ":" 之后的 tag;若不存在 ":",整串视为 tag;
3637
// 3. 再对 tag trim 空白(兼容异常配置);
37-
// 4. supports_mergerfs = (tag == v3CapBaseline)。
38-
func deriveEntryCapabilities(templateImageRef string) (imageVersion string, supportsMergerfs bool) {
38+
// 4. supports_mergerfs = imageLockSupportsMergerfs || (tag == v3CapBaseline)。
39+
// imageLockSupportsMergerfs 来自 image.lock 的显式声明,优先于 tag 推导,
40+
// 用于解决重建主机后数据库 template_image_ref 未同步更新的问题。
41+
func deriveEntryCapabilities(templateImageRef string, imageLockSupportsMergerfs bool) (imageVersion string, supportsMergerfs bool) {
42+
if imageLockSupportsMergerfs {
43+
tag := strings.TrimSpace(templateImageRef)
44+
if idx := strings.LastIndex(tag, ":"); idx != -1 {
45+
tag = tag[idx+1:]
46+
}
47+
tag = strings.TrimSpace(tag)
48+
return tag, true
49+
}
3950
tag := strings.TrimSpace(templateImageRef)
4051
if idx := strings.LastIndex(tag, ":"); idx != -1 {
4152
tag = tag[idx+1:]
@@ -46,13 +57,14 @@ func deriveEntryCapabilities(templateImageRef string) (imageVersion string, supp
4657
}
4758

4859
type EntryHandler struct {
49-
logger *slog.Logger
50-
store EntryStore
51-
baseURL string
60+
logger *slog.Logger
61+
store EntryStore
62+
baseURL string
63+
imageLockPath string
5264
}
5365

54-
func NewEntryHandler(logger *slog.Logger, store EntryStore, baseURL string) *EntryHandler {
55-
return &EntryHandler{logger: logger, store: store, baseURL: baseURL}
66+
func NewEntryHandler(logger *slog.Logger, store EntryStore, baseURL, imageLockPath string) *EntryHandler {
67+
return &EntryHandler{logger: logger, store: store, baseURL: baseURL, imageLockPath: imageLockPath}
5668
}
5769

5870
func (h *EntryHandler) Script() nethttp.Handler {
@@ -197,9 +209,13 @@ func (h *EntryHandler) Auth() nethttp.Handler {
197209
sshHost = sshHost[:idx]
198210
}
199211

200-
// Phase 30 D-06/D-07:仅依据 template_image_ref 推导能力字段,
201-
// 不访问 host-agent / Docker registry(D-04 维持 Phase 29 结论)。
202-
imageVersion, supportsMergerfs := deriveEntryCapabilities(templateImageRef)
212+
// Phase 30 D-06/D-07:依据 template_image_ref + image.lock 显式声明推导能力字段。
213+
// image.lock 的 supports_mergerfs 优先,解决重建主机后 DB 字段未同步问题。
214+
var imageLockSupportsMergerfs bool
215+
if spec, specErr := runtime.LoadRuntimeSpec(h.imageLockPath); specErr == nil {
216+
imageLockSupportsMergerfs = spec.SupportsMergerfs
217+
}
218+
imageVersion, supportsMergerfs := deriveEntryCapabilities(templateImageRef, imageLockSupportsMergerfs)
203219

204220
resp := map[string]any{
205221
"ssh_user": user.Username,

internal/controlplane/http/entry_auth_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ func mustBcrypt(t *testing.T, password string) string {
7171

7272
func doAuth(t *testing.T, store EntryStore, username, password string) (*httptest.ResponseRecorder, map[string]any) {
7373
t.Helper()
74-
handler := NewEntryHandler(slog.Default(), store, "").Auth()
74+
handler := NewEntryHandler(slog.Default(), store, "", "").Auth()
7575
body, _ := json.Marshal(map[string]string{"password": password})
7676
req := httptest.NewRequest(nethttp.MethodPost, "/v1/entry/"+username+"/auth", bytes.NewReader(body))
7777
req.Host = "gateway.example.com"

internal/controlplane/http/entry_caps_test.go

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,15 @@ import "testing"
44

55
// TestDeriveEntryCapabilities 覆盖 Phase 30 D-06/D-07:
66
// - image_version 来自 template_image_ref 最后一个 ":" 后的 tag(无 ":" 则整串),仅 trim 空白;
7-
// - supports_mergerfs 当且仅当 image_version == "v3.0.0" 时为 true
8-
// - 本阶段禁止引入任何额外的 tag 对照表
7+
// - supports_mergerfs = imageLockSupportsMergerfs || (image_version == "v3.0.0")
8+
// - image.lock 显式声明为 true 时优先覆盖 tag 推导,解决重建后 DB 字段未同步问题
99
func TestDeriveEntryCapabilities(t *testing.T) {
1010
tests := []struct {
11-
name string
12-
ref string
13-
wantVersion string
14-
wantMergerfs bool
11+
name string
12+
ref string
13+
imageLockSupportsMergerfs bool
14+
wantVersion string
15+
wantMergerfs bool
1516
}{
1617
{
1718
name: "v3.0.0 tag unlocks mergerfs",
@@ -55,10 +56,24 @@ func TestDeriveEntryCapabilities(t *testing.T) {
5556
wantVersion: "v3.0.0",
5657
wantMergerfs: true,
5758
},
59+
{
60+
name: "image.lock true overrides old tag",
61+
ref: "ghcr.io/example/cloud-claude:v2.0.0",
62+
imageLockSupportsMergerfs: true,
63+
wantVersion: "v2.0.0",
64+
wantMergerfs: true,
65+
},
66+
{
67+
name: "image.lock true works with any ref",
68+
ref: "cloudclaude-image",
69+
imageLockSupportsMergerfs: true,
70+
wantVersion: "cloudclaude-image",
71+
wantMergerfs: true,
72+
},
5873
}
5974
for _, tc := range tests {
6075
t.Run(tc.name, func(t *testing.T) {
61-
version, mergerfs := deriveEntryCapabilities(tc.ref)
76+
version, mergerfs := deriveEntryCapabilities(tc.ref, tc.imageLockSupportsMergerfs)
6277
if version != tc.wantVersion {
6378
t.Errorf("image_version = %q, want %q", version, tc.wantVersion)
6479
}

internal/controlplane/http/user_hosts.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,17 +121,10 @@ func (h *UserHostsHandler) Get() nethttp.Handler {
121121
host = host[:idx]
122122
}
123123
baseURL := fmt.Sprintf("%s://%s", scheme, r.Host)
124-
sshTarget := detail.Host.ShortID
125-
if sshTarget == "" {
126-
sshTarget = user.ShortID
127-
}
128-
if user.ShortID != "" || sshTarget != "" {
129-
entryID := detail.Host.ShortID
130-
if entryID == "" {
131-
entryID = user.ShortID
132-
}
124+
sshTarget := user.Username
125+
if sshTarget != "" {
133126
resp.ConnectionInfo = &repository.ConnectionInfo{
134-
CurlCommand: fmt.Sprintf("curl -sSL %s/entry/%s | bash", baseURL, entryID),
127+
CurlCommand: fmt.Sprintf("curl -sSL %s/entry/%s | bash", baseURL, sshTarget),
135128
SSHCommand: fmt.Sprintf("ssh %s@%s -p 2222", sshTarget, host),
136129
SSHPort: 2222,
137130
VNCURL: fmt.Sprintf("%s/v1/user/hosts/%s/vnc/vnc.html", baseURL, detail.Host.ID),

internal/network/container_proxy_provider.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"os"
99
"os/exec"
1010
"path/filepath"
11+
"runtime"
1112
"strings"
1213
"time"
1314
)
@@ -98,8 +99,12 @@ func (p *ContainerProxyProvider) PrepareHost(ctx context.Context, spec HostNetwo
9899
return fmt.Errorf("gateway: connect worker to network: %w", err)
99100
}
100101

101-
// 断开 Worker 原来的 bridge 网络,让隔离网络成为唯一出口
102-
_ = exec.CommandContext(ctx, "docker", "network", "disconnect", "-f", "bridge", workerName).Run()
102+
// 断开 Worker 原来的 bridge 网络,让隔离网络成为唯一出口。
103+
// macOS/Windows: 保留 bridge 网络,因为 Docker Desktop 宿主机无法直接路由到
104+
// 容器内部 IP,SSH 端口映射依赖 bridge 网络存活。
105+
if runtime.GOOS == "linux" {
106+
_ = exec.CommandContext(ctx, "docker", "network", "disconnect", "-f", "bridge", workerName).Run()
107+
}
103108

104109
// 等待隔离网络的接口就绪(disconnect 后可能有短暂延迟)
105110
time.Sleep(1 * time.Second)

internal/runtime/runtime_service.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ type RuntimeSpec struct {
2727
DefaultUser string
2828
HomeMount string
2929
RebuildModeDefault string
30+
SupportsMergerfs bool
3031
}
3132

3233
// QueueHostActionRepo 是 Service 在排队 host 操作时需要的仓储接口。
@@ -251,6 +252,7 @@ func LoadRuntimeSpec(path string) (RuntimeSpec, error) {
251252
DefaultUser: values["default_user"],
252253
HomeMount: values["home_mount"],
253254
RebuildModeDefault: firstNonEmpty(values["rebuild_mode_default"], defaultRebuildMode),
255+
SupportsMergerfs: values["supports_mergerfs"] == "true",
254256
}
255257

256258
if spec.ImageName == "" {

0 commit comments

Comments
 (0)