coderfleet 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- coderfleet/__init__.py +1 -0
- coderfleet/__main__.py +4 -0
- coderfleet/cli.py +212 -0
- coderfleet/compose.py +176 -0
- coderfleet/config.py +69 -0
- coderfleet/config_cmds.py +243 -0
- coderfleet/data/Dockerfile +92 -0
- coderfleet/data/__init__.py +0 -0
- coderfleet/data/accounts.conf.example +26 -0
- coderfleet/data/config.conf.example +31 -0
- coderfleet/data/entrypoint.sh +56 -0
- coderfleet/data/projects.conf.example +17 -0
- coderfleet/data/scripts/coderfleet_usage_status.py +138 -0
- coderfleet/docker_ops.py +385 -0
- coderfleet/init_wizard.py +227 -0
- coderfleet/login_cmd.py +168 -0
- coderfleet/server/__init__.py +0 -0
- coderfleet/server/docker_mgr.py +45 -0
- coderfleet/server/main.py +546 -0
- coderfleet/server/models.py +285 -0
- coderfleet/server/scheduler.py +1219 -0
- coderfleet/server/static/css/main.css +2906 -0
- coderfleet/server/static/index.html +378 -0
- coderfleet/server/static/js/accounts.js +85 -0
- coderfleet/server/static/js/app.js +28 -0
- coderfleet/server/static/js/chat.js +743 -0
- coderfleet/server/static/js/log.js +145 -0
- coderfleet/server/static/js/nav.js +46 -0
- coderfleet/server/static/js/projects.js +298 -0
- coderfleet/server/static/js/renderer.js +586 -0
- coderfleet/server/static/js/state.js +76 -0
- coderfleet/server/static/js/submit.js +200 -0
- coderfleet/server/static/js/tasks.js +92 -0
- coderfleet/server/static/js/terminal.js +347 -0
- coderfleet/server/static/js/utils.js +147 -0
- coderfleet/server/static/vendor/marked.min.js +6 -0
- coderfleet/server/static/vendor/xterm/addon-fit.js +2 -0
- coderfleet/server/static/vendor/xterm/xterm.css +218 -0
- coderfleet/server/static/vendor/xterm/xterm.js +2 -0
- coderfleet/server/terminal.py +129 -0
- coderfleet/task_cmds.py +311 -0
- coderfleet-0.1.0.dist-info/METADATA +492 -0
- coderfleet-0.1.0.dist-info/RECORD +45 -0
- coderfleet-0.1.0.dist-info/WHEEL +4 -0
- coderfleet-0.1.0.dist-info/entry_points.txt +2 -0
coderfleet/docker_ops.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
"""
|
|
2
|
+
docker_ops.py — Docker / compose 生命周期命令
|
|
3
|
+
|
|
4
|
+
build / apply / up / down / restart / status / logs / enter / check-proxy
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import click
|
|
15
|
+
|
|
16
|
+
from coderfleet.config import load_config, parse_conf
|
|
17
|
+
from coderfleet.compose import write_compose
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── helpers ───────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _dc_prefix() -> list[str]:
|
|
24
|
+
"""Detect docker compose v2 or legacy docker-compose."""
|
|
25
|
+
r = subprocess.run(["docker", "compose", "version"], capture_output=True, text=True)
|
|
26
|
+
if r.returncode == 0:
|
|
27
|
+
return ["docker", "compose"]
|
|
28
|
+
if shutil.which("docker-compose"):
|
|
29
|
+
return ["docker-compose"]
|
|
30
|
+
raise click.ClickException("找不到 docker compose 或 docker-compose,请先安装 Docker")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _dc(ws: Path) -> list[str]:
|
|
34
|
+
"""Full docker compose command prefix including -f flag."""
|
|
35
|
+
return _dc_prefix() + ["-f", str(ws / "docker-compose.yml")]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _container_name(project_name: str, acc_type: str) -> str:
|
|
39
|
+
return f"{acc_type}-{project_name}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _service_name(project_name: str, acc_type: str) -> str:
|
|
43
|
+
return f"{acc_type}-project-{project_name}"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _is_running(container: str) -> bool:
|
|
47
|
+
r = subprocess.run(
|
|
48
|
+
["docker", "inspect", container, "--format", "{{.State.Running}}"],
|
|
49
|
+
capture_output=True, text=True,
|
|
50
|
+
)
|
|
51
|
+
return r.stdout.strip() == "true"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _get_accounts(ws: Path) -> dict[str, dict[str, str]]:
|
|
55
|
+
return {r["NAME"]: r for r in parse_conf(ws / "accounts.conf") if "NAME" in r}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _get_projects(ws: Path) -> list[dict[str, str]]:
|
|
59
|
+
return [p for p in parse_conf(ws / "projects.conf") if "NAME" in p and "ACCOUNT" in p]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── commands ──────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@click.command("build")
|
|
66
|
+
@click.option("--no-cache", is_flag=True, help="不使用构建缓存(强制完整重新构建)")
|
|
67
|
+
@click.option("--pull", is_flag=True, help="强制拉取最新基础镜像")
|
|
68
|
+
@click.option("--platform", "platform_override", default=None, metavar="PLATFORM",
|
|
69
|
+
help="覆盖 config.conf 中的 BUILD_PLATFORM(如 linux/amd64)")
|
|
70
|
+
@click.option("--tag", "tag_override", default=None, metavar="TAG",
|
|
71
|
+
help="覆盖 config.conf 中的 IMAGE_TAG")
|
|
72
|
+
@click.pass_context
|
|
73
|
+
def cmd_build(
|
|
74
|
+
ctx: click.Context,
|
|
75
|
+
no_cache: bool,
|
|
76
|
+
pull: bool,
|
|
77
|
+
platform_override: Optional[str],
|
|
78
|
+
tag_override: Optional[str],
|
|
79
|
+
) -> None:
|
|
80
|
+
"""Build the Docker image."""
|
|
81
|
+
ws: Path = ctx.obj["workspace"]
|
|
82
|
+
cfg = load_config(ws)
|
|
83
|
+
|
|
84
|
+
dockerfile = ws / "Dockerfile"
|
|
85
|
+
if not dockerfile.exists():
|
|
86
|
+
raise click.ClickException(
|
|
87
|
+
f"找不到 Dockerfile({dockerfile}),请先执行 coderfleet init"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
image_name = cfg.get("IMAGE_NAME", "coderfleet")
|
|
91
|
+
image_tag = tag_override or cfg.get("IMAGE_TAG", "latest")
|
|
92
|
+
image = f"{image_name}:{image_tag}"
|
|
93
|
+
platform = platform_override or cfg.get("BUILD_PLATFORM", "linux/amd64")
|
|
94
|
+
|
|
95
|
+
click.echo(f"构建镜像 {image}(平台:{platform})...")
|
|
96
|
+
if no_cache:
|
|
97
|
+
click.secho(" --no-cache:跳过所有缓存层", fg="yellow")
|
|
98
|
+
if pull:
|
|
99
|
+
click.secho(" --pull:强制拉取最新基础镜像", fg="yellow")
|
|
100
|
+
click.secho("首次构建约需 5~10 分钟,请耐心等待", dim=True)
|
|
101
|
+
click.echo()
|
|
102
|
+
|
|
103
|
+
cmd = [
|
|
104
|
+
"docker", "build",
|
|
105
|
+
"--platform", platform,
|
|
106
|
+
"--tag", image,
|
|
107
|
+
"--file", str(dockerfile),
|
|
108
|
+
]
|
|
109
|
+
if no_cache:
|
|
110
|
+
cmd.append("--no-cache")
|
|
111
|
+
if pull:
|
|
112
|
+
cmd.append("--pull")
|
|
113
|
+
cmd.append(str(ws))
|
|
114
|
+
|
|
115
|
+
result = subprocess.run(cmd)
|
|
116
|
+
|
|
117
|
+
if result.returncode != 0:
|
|
118
|
+
raise click.ClickException("镜像构建失败")
|
|
119
|
+
|
|
120
|
+
click.echo()
|
|
121
|
+
click.secho(f"✓ 镜像构建完成:{image}", fg="green")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@click.command("apply")
|
|
125
|
+
@click.pass_context
|
|
126
|
+
def cmd_apply(ctx: click.Context) -> None:
|
|
127
|
+
"""Regenerate docker-compose.yml and restart all containers."""
|
|
128
|
+
ws: Path = ctx.obj["workspace"]
|
|
129
|
+
|
|
130
|
+
click.echo("生成 docker-compose.yml...")
|
|
131
|
+
write_compose(ws)
|
|
132
|
+
click.secho("✓ docker-compose.yml 已生成", fg="green")
|
|
133
|
+
click.echo()
|
|
134
|
+
|
|
135
|
+
click.echo("重启容器以应用新配置...")
|
|
136
|
+
dc = _dc(ws)
|
|
137
|
+
subprocess.run(dc + ["down", "--remove-orphans"])
|
|
138
|
+
result = subprocess.run(dc + ["up", "-d"])
|
|
139
|
+
|
|
140
|
+
if result.returncode != 0:
|
|
141
|
+
raise click.ClickException("容器启动失败")
|
|
142
|
+
|
|
143
|
+
click.echo()
|
|
144
|
+
click.secho("✓ 完成!使用 coderfleet status 查看状态", fg="green")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@click.command("up")
|
|
148
|
+
@click.pass_context
|
|
149
|
+
def cmd_up(ctx: click.Context) -> None:
|
|
150
|
+
"""Start all containers."""
|
|
151
|
+
ws: Path = ctx.obj["workspace"]
|
|
152
|
+
compose_file = ws / "docker-compose.yml"
|
|
153
|
+
|
|
154
|
+
if not compose_file.exists():
|
|
155
|
+
click.secho("docker-compose.yml 不存在,先生成...", fg="yellow")
|
|
156
|
+
write_compose(ws)
|
|
157
|
+
click.echo()
|
|
158
|
+
|
|
159
|
+
click.echo("启动所有容器...")
|
|
160
|
+
result = subprocess.run(_dc(ws) + ["up", "-d"])
|
|
161
|
+
if result.returncode == 0:
|
|
162
|
+
click.secho("✓ 启动完成", fg="green")
|
|
163
|
+
else:
|
|
164
|
+
raise click.ClickException("启动失败")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@click.command("down")
|
|
168
|
+
@click.pass_context
|
|
169
|
+
def cmd_down(ctx: click.Context) -> None:
|
|
170
|
+
"""Stop all containers."""
|
|
171
|
+
ws: Path = ctx.obj["workspace"]
|
|
172
|
+
click.echo("停止所有容器...")
|
|
173
|
+
subprocess.run(_dc(ws) + ["down"])
|
|
174
|
+
click.secho("✓ 已停止", fg="green")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
@click.command("restart")
|
|
178
|
+
@click.pass_context
|
|
179
|
+
def cmd_restart(ctx: click.Context) -> None:
|
|
180
|
+
"""Restart all containers."""
|
|
181
|
+
ws: Path = ctx.obj["workspace"]
|
|
182
|
+
click.echo("重启...")
|
|
183
|
+
dc = _dc(ws)
|
|
184
|
+
subprocess.run(dc + ["down"])
|
|
185
|
+
result = subprocess.run(dc + ["up", "-d"])
|
|
186
|
+
if result.returncode == 0:
|
|
187
|
+
click.secho("✓ 重启完成", fg="green")
|
|
188
|
+
else:
|
|
189
|
+
raise click.ClickException("重启失败")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@click.command("status")
|
|
193
|
+
@click.pass_context
|
|
194
|
+
def cmd_status(ctx: click.Context) -> None:
|
|
195
|
+
"""Show container and image status."""
|
|
196
|
+
ws: Path = ctx.obj["workspace"]
|
|
197
|
+
cfg = load_config(ws)
|
|
198
|
+
accounts = _get_accounts(ws)
|
|
199
|
+
projects = _get_projects(ws)
|
|
200
|
+
|
|
201
|
+
click.echo()
|
|
202
|
+
click.echo(" ── 项目与容器状态 " + "─" * 50)
|
|
203
|
+
|
|
204
|
+
for p in projects:
|
|
205
|
+
pname = p["NAME"]
|
|
206
|
+
paccount = p.get("ACCOUNT", "")
|
|
207
|
+
acc = accounts.get(paccount, {})
|
|
208
|
+
acc_type = acc.get("TYPE", "")
|
|
209
|
+
ctr = _container_name(pname, acc_type) if acc_type else pname
|
|
210
|
+
|
|
211
|
+
if _is_running(ctr):
|
|
212
|
+
status = click.style("● 运行中", fg="green")
|
|
213
|
+
else:
|
|
214
|
+
status = click.style("○ 已停止", fg="red")
|
|
215
|
+
|
|
216
|
+
click.echo(f" {pname:<20} [{acc_type:<6}] {status} 账号:{paccount}")
|
|
217
|
+
|
|
218
|
+
click.echo()
|
|
219
|
+
click.echo(" ── 代理中继 " + "─" * 56)
|
|
220
|
+
r = subprocess.run(
|
|
221
|
+
["docker", "inspect", "coderfleet-proxy-relay",
|
|
222
|
+
"--format", "{{.State.Health.Status}}"],
|
|
223
|
+
capture_output=True, text=True,
|
|
224
|
+
)
|
|
225
|
+
health = r.stdout.strip() if r.returncode == 0 else "未运行"
|
|
226
|
+
if health == "healthy":
|
|
227
|
+
click.echo(f" coderfleet-proxy-relay: {click.style(health, fg='green')}")
|
|
228
|
+
elif health == "starting":
|
|
229
|
+
click.echo(f" coderfleet-proxy-relay: {click.style(health + '(启动中)', fg='yellow')}")
|
|
230
|
+
else:
|
|
231
|
+
click.echo(f" coderfleet-proxy-relay: {click.style(health, fg='red')}")
|
|
232
|
+
|
|
233
|
+
click.echo()
|
|
234
|
+
click.echo(" ── 镜像信息 " + "─" * 56)
|
|
235
|
+
image = f"{cfg.get('IMAGE_NAME', 'coderfleet')}:{cfg.get('IMAGE_TAG', 'latest')}"
|
|
236
|
+
ri = subprocess.run(
|
|
237
|
+
["docker", "image", "inspect", image,
|
|
238
|
+
"--format", "{{.Created}}\t{{.Size}}"],
|
|
239
|
+
capture_output=True, text=True,
|
|
240
|
+
)
|
|
241
|
+
if ri.returncode == 0 and ri.stdout.strip():
|
|
242
|
+
created, size_str = ri.stdout.strip().split("\t", 1)
|
|
243
|
+
try:
|
|
244
|
+
size_gb = int(size_str) / 1024 / 1024 / 1024
|
|
245
|
+
size_label = f"{size_gb:.1f} GB"
|
|
246
|
+
except ValueError:
|
|
247
|
+
size_label = size_str
|
|
248
|
+
click.echo(
|
|
249
|
+
f" {image}: {click.style('已构建', fg='green')}"
|
|
250
|
+
f"(创建于 {created[:10]},大小 {size_label})"
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
click.echo(
|
|
254
|
+
f" {image}: {click.style('未构建', fg='red')},请执行 coderfleet build"
|
|
255
|
+
)
|
|
256
|
+
click.echo()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@click.command("logs")
|
|
260
|
+
@click.argument("project", required=False)
|
|
261
|
+
@click.pass_context
|
|
262
|
+
def cmd_logs(ctx: click.Context, project: Optional[str]) -> None:
|
|
263
|
+
"""Stream container logs (all containers, or a specific project)."""
|
|
264
|
+
ws: Path = ctx.obj["workspace"]
|
|
265
|
+
dc = _dc(ws)
|
|
266
|
+
|
|
267
|
+
if not project:
|
|
268
|
+
subprocess.run(dc + ["logs", "-f"])
|
|
269
|
+
return
|
|
270
|
+
|
|
271
|
+
accounts = _get_accounts(ws)
|
|
272
|
+
for p in _get_projects(ws):
|
|
273
|
+
if p["NAME"] != project:
|
|
274
|
+
continue
|
|
275
|
+
paccount = p.get("ACCOUNT", "")
|
|
276
|
+
acc_type = accounts.get(paccount, {}).get("TYPE", "")
|
|
277
|
+
svc = _service_name(project, acc_type)
|
|
278
|
+
subprocess.run(dc + ["logs", "-f", svc])
|
|
279
|
+
return
|
|
280
|
+
|
|
281
|
+
raise click.ClickException(f"项目 '{project}' 不在 projects.conf 中")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@click.command("enter")
|
|
285
|
+
@click.argument("project")
|
|
286
|
+
@click.pass_context
|
|
287
|
+
def cmd_enter(ctx: click.Context, project: str) -> None:
|
|
288
|
+
"""Enter a container shell (replaces current process)."""
|
|
289
|
+
ws: Path = ctx.obj["workspace"]
|
|
290
|
+
accounts = _get_accounts(ws)
|
|
291
|
+
|
|
292
|
+
for p in _get_projects(ws):
|
|
293
|
+
if p["NAME"] != project:
|
|
294
|
+
continue
|
|
295
|
+
paccount = p.get("ACCOUNT", "")
|
|
296
|
+
acc_type = accounts.get(paccount, {}).get("TYPE", "")
|
|
297
|
+
ctr = _container_name(project, acc_type)
|
|
298
|
+
|
|
299
|
+
if not _is_running(ctr):
|
|
300
|
+
raise click.ClickException(f"容器 {ctr} 未运行,请先执行:coderfleet up")
|
|
301
|
+
|
|
302
|
+
click.secho(f"进入 {ctr}(类型:{acc_type})...", dim=True)
|
|
303
|
+
os.execvp("docker", ["docker", "exec", "-it", ctr, "bash"])
|
|
304
|
+
|
|
305
|
+
raise click.ClickException(f"项目 '{project}' 不在 projects.conf 中")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@click.command("check-proxy")
|
|
309
|
+
@click.pass_context
|
|
310
|
+
def cmd_check_proxy(ctx: click.Context) -> None:
|
|
311
|
+
"""Check proxy connectivity for all containers."""
|
|
312
|
+
ws: Path = ctx.obj["workspace"]
|
|
313
|
+
cfg = load_config(ws)
|
|
314
|
+
accounts = _get_accounts(ws)
|
|
315
|
+
projects = _get_projects(ws)
|
|
316
|
+
|
|
317
|
+
relay_ip = cfg.get("RELAY_IP", "172.21.0.2")
|
|
318
|
+
relay_port = cfg.get("RELAY_LISTEN_PORT", "7890")
|
|
319
|
+
|
|
320
|
+
click.echo()
|
|
321
|
+
click.echo(" ── 代理连通性(应全部通)" + "─" * 43)
|
|
322
|
+
for p in projects:
|
|
323
|
+
pname = p["NAME"]
|
|
324
|
+
paccount = p.get("ACCOUNT", "")
|
|
325
|
+
acc_type = accounts.get(paccount, {}).get("TYPE", "")
|
|
326
|
+
ctr = _container_name(pname, acc_type)
|
|
327
|
+
|
|
328
|
+
click.echo(f" {ctr:<28} → proxy-relay: ", nl=False)
|
|
329
|
+
if not _is_running(ctr):
|
|
330
|
+
click.secho("容器未运行", fg="yellow")
|
|
331
|
+
continue
|
|
332
|
+
r = subprocess.run(
|
|
333
|
+
["docker", "exec", ctr, "sh", "-c", f"nc -z {relay_ip} {relay_port}"],
|
|
334
|
+
capture_output=True,
|
|
335
|
+
)
|
|
336
|
+
if r.returncode == 0:
|
|
337
|
+
click.secho("通", fg="green")
|
|
338
|
+
else:
|
|
339
|
+
click.secho("不通", fg="red")
|
|
340
|
+
|
|
341
|
+
click.echo()
|
|
342
|
+
click.echo(" ── 直连公网封锁(应全部封锁)" + "─" * 40)
|
|
343
|
+
for p in projects:
|
|
344
|
+
pname = p["NAME"]
|
|
345
|
+
paccount = p.get("ACCOUNT", "")
|
|
346
|
+
acc_type = accounts.get(paccount, {}).get("TYPE", "")
|
|
347
|
+
ctr = _container_name(pname, acc_type)
|
|
348
|
+
|
|
349
|
+
click.echo(f" {ctr:<28} → 8.8.8.8:443: ", nl=False)
|
|
350
|
+
if not _is_running(ctr):
|
|
351
|
+
click.secho("容器未运行", fg="yellow")
|
|
352
|
+
continue
|
|
353
|
+
r = subprocess.run(
|
|
354
|
+
["docker", "exec", ctr, "sh", "-c", "timeout 3 nc -z 8.8.8.8 443 2>/dev/null"],
|
|
355
|
+
capture_output=True,
|
|
356
|
+
)
|
|
357
|
+
if r.returncode == 0:
|
|
358
|
+
click.secho("可直连 ← 隔离未生效!", fg="red")
|
|
359
|
+
else:
|
|
360
|
+
click.secho("已封锁", fg="green")
|
|
361
|
+
|
|
362
|
+
click.echo()
|
|
363
|
+
click.echo(" ── 代理出口 IP " + "─" * 52)
|
|
364
|
+
for p in projects:
|
|
365
|
+
pname = p["NAME"]
|
|
366
|
+
paccount = p.get("ACCOUNT", "")
|
|
367
|
+
acc_type = accounts.get(paccount, {}).get("TYPE", "")
|
|
368
|
+
ctr = _container_name(pname, acc_type)
|
|
369
|
+
|
|
370
|
+
click.echo(f" {ctr:<28} 出口 IP: ", nl=False)
|
|
371
|
+
if not _is_running(ctr):
|
|
372
|
+
click.secho("容器未运行", fg="yellow")
|
|
373
|
+
continue
|
|
374
|
+
r = subprocess.run(
|
|
375
|
+
["docker", "exec", ctr, "sh", "-c",
|
|
376
|
+
"curl -s --max-time 6 https://api.ipify.org 2>/dev/null"],
|
|
377
|
+
capture_output=True, text=True,
|
|
378
|
+
)
|
|
379
|
+
ip = r.stdout.strip()
|
|
380
|
+
if ip:
|
|
381
|
+
click.secho(ip, fg="green")
|
|
382
|
+
else:
|
|
383
|
+
click.secho("请求失败", fg="yellow")
|
|
384
|
+
|
|
385
|
+
click.echo()
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""
|
|
2
|
+
init_wizard.py — coderfleet init 交互式初始化向导
|
|
3
|
+
|
|
4
|
+
向导流程:
|
|
5
|
+
1. 确认工作区路径
|
|
6
|
+
2. 创建目录结构
|
|
7
|
+
3. 配置代理与镜像参数 → 写入 config.conf
|
|
8
|
+
4. 拷贝 Dockerfile / entrypoint.sh 到工作区
|
|
9
|
+
5. 创建空白 accounts.conf / projects.conf(如不存在)
|
|
10
|
+
6. 打印后续步骤提示
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import importlib.resources
|
|
15
|
+
import shutil
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from coderfleet.config import ensure_workspace, get_workspace, load_config
|
|
22
|
+
|
|
23
|
+
# ── 工具函数 ──────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _data_file(name: str) -> Path:
|
|
27
|
+
"""返回 coderfleet/data/ 中打包资源文件的路径。"""
|
|
28
|
+
pkg = importlib.resources.files("coderfleet.data")
|
|
29
|
+
return Path(str(pkg / name))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write_config(ws: Path, values: dict[str, str]) -> None:
|
|
33
|
+
"""将 key=value 对写入 config.conf(整体覆盖)。"""
|
|
34
|
+
conf_path = ws / "config.conf"
|
|
35
|
+
lines = [
|
|
36
|
+
"# ============================================================",
|
|
37
|
+
"# config.conf — CoderFleet 全局配置",
|
|
38
|
+
"# 修改后执行 coderfleet apply 生效",
|
|
39
|
+
"# ============================================================",
|
|
40
|
+
"",
|
|
41
|
+
"# ── 镜像配置 ──────────────────────────────────────────────",
|
|
42
|
+
f"IMAGE_NAME={values['IMAGE_NAME']}",
|
|
43
|
+
f"IMAGE_TAG={values['IMAGE_TAG']}",
|
|
44
|
+
f"BUILD_PLATFORM={values['BUILD_PLATFORM']}",
|
|
45
|
+
"",
|
|
46
|
+
"# ── 代理配置 ──────────────────────────────────────────────",
|
|
47
|
+
"# 宿主机代理地址(容器通过 host.docker.internal 访问宿主机)",
|
|
48
|
+
f"PROXY_HOST={values['PROXY_HOST']}",
|
|
49
|
+
f"PROXY_HTTP_PORT={values['PROXY_HTTP_PORT']}",
|
|
50
|
+
f"PROXY_SOCKS5_PORT={values['PROXY_SOCKS5_PORT']}",
|
|
51
|
+
"",
|
|
52
|
+
"# ── 内部网络配置 ──────────────────────────────────────────",
|
|
53
|
+
f"INTERNAL_SUBNET={values['INTERNAL_SUBNET']}",
|
|
54
|
+
f"RELAY_IP={values['RELAY_IP']}",
|
|
55
|
+
f"RELAY_LISTEN_PORT={values['RELAY_LISTEN_PORT']}",
|
|
56
|
+
f"RELAY_IMAGE={values['RELAY_IMAGE']}",
|
|
57
|
+
"",
|
|
58
|
+
]
|
|
59
|
+
conf_path.write_text("\n".join(lines), encoding="utf-8")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _create_empty_conf(path: Path, example_name: str) -> None:
|
|
63
|
+
"""拷贝 example 文件作为初始 conf(保留注释,去掉示例数据行)。"""
|
|
64
|
+
if path.exists():
|
|
65
|
+
return
|
|
66
|
+
src = _data_file(example_name)
|
|
67
|
+
if src.exists():
|
|
68
|
+
# 复制 example,但去掉实际数据行(非注释/非空的行)
|
|
69
|
+
lines = src.read_text(encoding="utf-8").splitlines(keepends=True)
|
|
70
|
+
kept = []
|
|
71
|
+
for line in lines:
|
|
72
|
+
stripped = line.strip()
|
|
73
|
+
# 保留空行和注释行;去掉 example 末尾的示例数据行
|
|
74
|
+
if not stripped or stripped.startswith("#"):
|
|
75
|
+
kept.append(line)
|
|
76
|
+
path.write_text("".join(kept), encoding="utf-8")
|
|
77
|
+
else:
|
|
78
|
+
path.write_text("", encoding="utf-8")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ── 向导主体 ──────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
def run_init_wizard(ws: Path) -> None:
|
|
84
|
+
"""交互式初始化工作区。"""
|
|
85
|
+
click.echo()
|
|
86
|
+
click.secho("CoderFleet 初始化向导", bold=True)
|
|
87
|
+
click.echo("=" * 40)
|
|
88
|
+
|
|
89
|
+
# ── 步骤 1:确认工作区 ──────────────────────────────
|
|
90
|
+
click.echo(f"\n工作区目录:{click.style(str(ws), fg='cyan')}")
|
|
91
|
+
if ws.exists() and (ws / "config.conf").exists():
|
|
92
|
+
if not click.confirm("config.conf 已存在,是否重新配置?", default=False):
|
|
93
|
+
click.echo("跳过配置,仅补全缺失目录和文件。")
|
|
94
|
+
ensure_workspace(ws)
|
|
95
|
+
_copy_runtime_files(ws)
|
|
96
|
+
_init_empty_confs(ws)
|
|
97
|
+
_print_next_steps(ws)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
ensure_workspace(ws)
|
|
101
|
+
click.secho("✓ 工作区目录已就绪", fg="green")
|
|
102
|
+
|
|
103
|
+
# ── 步骤 2:代理配置 ────────────────────────────────
|
|
104
|
+
click.echo("\n── 代理配置 ──────────────────────────────────────")
|
|
105
|
+
click.echo("容器通过宿主机代理访问互联网(Clash / v2ray / sing-box 等)。")
|
|
106
|
+
|
|
107
|
+
proxy_host = click.prompt(
|
|
108
|
+
"代理地址",
|
|
109
|
+
default="host.docker.internal",
|
|
110
|
+
)
|
|
111
|
+
proxy_http_port = click.prompt(
|
|
112
|
+
"代理 HTTP 端口",
|
|
113
|
+
default="10808",
|
|
114
|
+
)
|
|
115
|
+
same_port = click.confirm(
|
|
116
|
+
f"SOCKS5 端口与 HTTP 端口相同({proxy_http_port})",
|
|
117
|
+
default=True,
|
|
118
|
+
)
|
|
119
|
+
proxy_socks5_port = proxy_http_port if same_port else click.prompt(
|
|
120
|
+
"代理 SOCKS5 端口",
|
|
121
|
+
default="10808",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# ── 步骤 3:镜像配置 ────────────────────────────────
|
|
125
|
+
click.echo("\n── Docker 镜像配置 ───────────────────────────────")
|
|
126
|
+
|
|
127
|
+
import platform as _platform
|
|
128
|
+
default_platform = (
|
|
129
|
+
"linux/arm64" if _platform.machine() in ("arm64", "aarch64") else "linux/amd64"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
image_name = click.prompt("镜像名称", default="coderfleet")
|
|
133
|
+
image_tag = click.prompt("镜像标签", default="latest")
|
|
134
|
+
build_platform = click.prompt("构建平台", default=default_platform)
|
|
135
|
+
|
|
136
|
+
# ── 步骤 4:写入 config.conf ─────────────────────────
|
|
137
|
+
relay_listen_port = proxy_http_port # relay 监听同一端口转发给宿主机
|
|
138
|
+
values = {
|
|
139
|
+
"IMAGE_NAME": image_name,
|
|
140
|
+
"IMAGE_TAG": image_tag,
|
|
141
|
+
"BUILD_PLATFORM": build_platform,
|
|
142
|
+
"PROXY_HOST": proxy_host,
|
|
143
|
+
"PROXY_HTTP_PORT": proxy_http_port,
|
|
144
|
+
"PROXY_SOCKS5_PORT": proxy_socks5_port,
|
|
145
|
+
"INTERNAL_SUBNET": "172.21.0.0/16",
|
|
146
|
+
"RELAY_IP": "172.21.0.2",
|
|
147
|
+
"RELAY_LISTEN_PORT": relay_listen_port,
|
|
148
|
+
"RELAY_IMAGE": "gogost/gost:3",
|
|
149
|
+
}
|
|
150
|
+
_write_config(ws, values)
|
|
151
|
+
click.secho("✓ config.conf 已生成", fg="green")
|
|
152
|
+
|
|
153
|
+
# ── 步骤 5:拷贝运行时文件 ───────────────────────────
|
|
154
|
+
_copy_runtime_files(ws)
|
|
155
|
+
|
|
156
|
+
# ── 步骤 6:初始化 conf 文件 ─────────────────────────
|
|
157
|
+
_init_empty_confs(ws)
|
|
158
|
+
|
|
159
|
+
_print_next_steps(ws)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _copy_runtime_files(ws: Path) -> None:
|
|
163
|
+
"""将 Dockerfile、entrypoint.sh 和 scripts/ 拷贝到工作区(已存在则跳过)。"""
|
|
164
|
+
for name in ("Dockerfile", "entrypoint.sh"):
|
|
165
|
+
dst = ws / name
|
|
166
|
+
if dst.exists():
|
|
167
|
+
click.echo(f" {name} 已存在,跳过")
|
|
168
|
+
continue
|
|
169
|
+
src = _data_file(name)
|
|
170
|
+
if src.exists():
|
|
171
|
+
shutil.copy2(str(src), str(dst))
|
|
172
|
+
if name.endswith(".sh"):
|
|
173
|
+
dst.chmod(0o755)
|
|
174
|
+
click.secho(f"✓ {name} 已拷贝", fg="green")
|
|
175
|
+
else:
|
|
176
|
+
click.secho(f"警告:找不到内置 {name},请手动放置", fg="yellow")
|
|
177
|
+
|
|
178
|
+
# scripts/ — Dockerfile 构建时需要的辅助脚本
|
|
179
|
+
scripts_src = Path(str(_data_file("scripts")))
|
|
180
|
+
scripts_dst = ws / "scripts"
|
|
181
|
+
if scripts_src.is_dir():
|
|
182
|
+
scripts_dst.mkdir(exist_ok=True)
|
|
183
|
+
for f in scripts_src.iterdir():
|
|
184
|
+
dst = scripts_dst / f.name
|
|
185
|
+
if dst.exists():
|
|
186
|
+
continue
|
|
187
|
+
shutil.copy2(str(f), str(dst))
|
|
188
|
+
click.secho("✓ scripts/ 已拷贝", fg="green")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _init_empty_confs(ws: Path) -> None:
|
|
192
|
+
"""创建空白的 accounts.conf 和 projects.conf(如不存在)。"""
|
|
193
|
+
for fname, example in (
|
|
194
|
+
("accounts.conf", "accounts.conf.example"),
|
|
195
|
+
("projects.conf", "projects.conf.example"),
|
|
196
|
+
):
|
|
197
|
+
path = ws / fname
|
|
198
|
+
if not path.exists():
|
|
199
|
+
_create_empty_conf(path, example)
|
|
200
|
+
click.secho(f"✓ {fname} 已创建(空白,仅含注释)", fg="green")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _print_next_steps(ws: Path) -> None:
|
|
204
|
+
"""打印后续操作提示。"""
|
|
205
|
+
click.echo()
|
|
206
|
+
click.secho("初始化完成!后续步骤:", bold=True)
|
|
207
|
+
click.echo()
|
|
208
|
+
click.echo(f" 1. 构建 Docker 镜像(首次约 5-10 分钟):")
|
|
209
|
+
click.secho(f" coderfleet build", fg="cyan")
|
|
210
|
+
click.echo(f" 2. 添加账号:")
|
|
211
|
+
click.secho(f" coderfleet account add <名称> TYPE=claude", fg="cyan")
|
|
212
|
+
click.echo(f" 3. 添加项目:")
|
|
213
|
+
click.secho(f" coderfleet project add <名称> <账号名> ~/projects/myproject", fg="cyan")
|
|
214
|
+
click.echo(f" 4. 应用配置并启动容器:")
|
|
215
|
+
click.secho(f" coderfleet apply", fg="cyan")
|
|
216
|
+
click.echo(f" 5. 登录账号:")
|
|
217
|
+
click.secho(f" coderfleet login <账号名>", fg="cyan")
|
|
218
|
+
click.echo(f" 6. 启动 Web UI:")
|
|
219
|
+
click.secho(f" coderfleet server", fg="cyan")
|
|
220
|
+
click.echo()
|
|
221
|
+
click.echo(f"工作区位置:{ws}")
|
|
222
|
+
if ws != Path.home() / ".coderfleet":
|
|
223
|
+
click.echo(
|
|
224
|
+
f"提示:非默认工作区,运行 coderfleet 命令时需设置环境变量:\n"
|
|
225
|
+
f" export CODERFLEET_WORKSPACE={ws}"
|
|
226
|
+
)
|
|
227
|
+
click.echo()
|