-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrun_docker_in_server.sh
More file actions
executable file
·911 lines (786 loc) · 30.8 KB
/
Copy pathrun_docker_in_server.sh
File metadata and controls
executable file
·911 lines (786 loc) · 30.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
#!/usr/bin/env bash
set -euo pipefail
# 显式命令式 Docker 部署脚本(仿 pi_ai/examples/seedance_agent_with_skills/run_docker_in_server.sh)。
# 用 docker 直接构建/运行,不依赖 docker-compose。
# 镜像 tag 取自 git commit hash 前缀,便于追溯版本。
#
# 可按需覆盖这些变量:
# REPO_ROOT=/path/to/codebuddy2api
# HOST_PORT=8111
# CONTAINER_NAME=codebuddy2api
# DATA_ROOT=/path/to/data
# IMAGE_REPOSITORY=codebuddy2api
# IMAGE_TAG=<git-hash-prefix>
# IMAGE_NAME=<repository:tag>
# 仓库根目录:默认为脚本所在目录,可通过 REPO_ROOT 覆盖
REPO_ROOT="${REPO_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}"
# Docker/Podman 可执行文件:默认 docker,可覆盖为 podman 等。
# 注意:shell alias(如 docker=podman)不会被子进程继承,本脚本是非交互式 shell,
# 若系统只有 podman 而无真实 docker 二进制,请设 DOCKER_BIN=podman。
DOCKER_BIN="${DOCKER_BIN:-docker}"
# 容器名称:可通过 CONTAINER_NAME 覆盖
CONTAINER_NAME="${CONTAINER_NAME:-codebuddy2api}"
# 宿主机暴露端口:可通过 HOST_PORT 覆盖
HOST_PORT="${HOST_PORT:-8111}"
# 容器内部监听端口:可通过 CONTAINER_PORT 覆盖
CONTAINER_PORT="${CONTAINER_PORT:-8111}"
# 数据持久化根目录:其下设 config/ creds/ logs/ 三个子目录,可通过 DATA_ROOT 覆盖
DATA_ROOT="${DATA_ROOT:-$REPO_ROOT/data}"
# 受保护镜像清单文件(每行一个 tag):列在此文件的镜像不会被自动清理,
# rm-image 删除时需 --force。跨实例共享,放仓库根。
PROTECTED_IMAGES_FILE="${PROTECTED_IMAGES_FILE:-$REPO_ROOT/.protected-images}"
# 镜像仓库名:可通过 IMAGE_REPOSITORY 覆盖
IMAGE_REPOSITORY="${IMAGE_REPOSITORY:-codebuddy2api}"
# 本地保留的镜像数量:0 表示不清理,可通过 IMAGE_RETENTION_COUNT 覆盖
IMAGE_RETENTION_COUNT="${IMAGE_RETENTION_COUNT:-5}"
# 镜像 tag 截取的 hash 长度:可通过 IMAGE_TAG_LENGTH 覆盖
IMAGE_TAG_LENGTH="${IMAGE_TAG_LENGTH:-8}"
# 启动就绪探测 URL:默认探测本地 /health,可通过 STARTUP_READY_URL 覆盖
STARTUP_READY_URL="${STARTUP_READY_URL:-http://127.0.0.1:${HOST_PORT}/health}"
# 启动就绪探测总超时时间(秒)
STARTUP_READY_TIMEOUT_SECONDS="${STARTUP_READY_TIMEOUT_SECONDS:-60}"
# 启动就绪探测轮询间隔(秒)
STARTUP_READY_INTERVAL_SECONDS="${STARTUP_READY_INTERVAL_SECONDS:-2}"
# 单次 curl 请求超时时间(秒)
STARTUP_READY_REQUEST_TIMEOUT_SECONDS="${STARTUP_READY_REQUEST_TIMEOUT_SECONDS:-2}"
# 构建时注入镜像的 git commit hash(build-arg 名,与 Dockerfile 一致)
BUILD_ARG_NAME="CODEBUDDY2API_GIT_COMMIT_HASH"
# 标记 IMAGE_NAME 是否由外部显式传入(1=已设置,0=未设置)
IMAGE_NAME_WAS_SET=0
if [ -n "${IMAGE_NAME:-}" ]; then
IMAGE_NAME_WAS_SET=1
fi
# 标记 IMAGE_REPOSITORY 是否由外部显式传入
IMAGE_REPOSITORY_WAS_SET=0
if [ -n "${IMAGE_REPOSITORY:-}" ]; then
IMAGE_REPOSITORY_WAS_SET=1
fi
# 标记 IMAGE_TAG 是否由外部显式传入
IMAGE_TAG_WAS_SET=0
if [ -n "${IMAGE_TAG:-}" ]; then
IMAGE_TAG_WAS_SET=1
fi
# 脚本接收的第一个参数作为子命令
COMMAND="${1:-}"
# 解析到的 git commit hash(完整)
GIT_COMMIT_HASH=""
# 打印环境变量通用说明(供 usage 和各子命令帮助复用)
print_env_overview() {
cat <<'EOF'
环境变量(均可在命令前用 VAR=value 覆盖):
基础:
REPO_ROOT 仓库根目录(默认: 脚本所在目录)
DOCKER_BIN docker/podman 可执行文件(默认: docker)
注: shell alias 不会被子进程继承,
只有 podman 时需显式 DOCKER_BIN=podman
CONTAINER_NAME 容器名(默认: codebuddy2api)
HOST_PORT 宿主机暴露端口(默认: 8111)
CONTAINER_PORT 容器内监听端口(默认: 8111)
DATA_ROOT 持久化根目录(默认: $REPO_ROOT/data)
其下设 config/ creds/ logs/ 三个子目录
IMAGE_REPOSITORY 镜像仓库名(默认: codebuddy2api)
IMAGE_TAG 显式指定镜像 tag
IMAGE_NAME 显式指定完整镜像名(含 tag, 优先级最高)
IMAGE_TAG_LENGTH git hash 截取长度(默认: 8, 须 6~hash 长度)
IMAGE_RETENTION_COUNT 本地保留镜像数(默认: 5, 0=不清理)
构建行为控制:
CODEBUDDY2API_GIT_COMMIT_HASH 显式指定构建用的完整 git hash(40/64 位)
ALLOW_DIRTY_BUILD=1 工作区有未提交修改时仍允许构建
ALLOW_UNVERSIONED_BUILD=1 非 git 仓库时允许构建(tag=local)
FORCE_REBUILD=1 同 tag 且 revision 相同时强制重建
SKIP_CONFIG_ADAPT=1 跳过自动把 config.yaml 的 server.host 改为 0.0.0.0
就绪探测(deploy/start 等待服务就绪用):
STARTUP_READY_URL 就绪探测 URL(默认: http://127.0.0.1:$HOST_PORT/health)
STARTUP_READY_TIMEOUT_SECONDS 就绪探测总超时(默认: 60, 非负整数)
STARTUP_READY_INTERVAL_SECONDS 轮询间隔(默认: 2, 正整数)
STARTUP_READY_REQUEST_TIMEOUT_SECONDS 单次 curl 超时(默认: 2, 正整数)
EOF
}
# 各子命令的详细帮助
help_build() {
cat <<'EOF'
build — 仅构建镜像
用法:
./run_docker_in_server.sh build
行为:
按 git commit hash 打 tag 构建镜像。不启动容器, 不清理旧镜像。
适合 CI 构建推送, 或先 build 再单独 start 的场景。
构建流程:
1. 解析 git commit hash: CODEBUDDY2API_GIT_COMMIT_HASH > git HEAD > local
2. 检查工作区是否干净(有未提交修改需 ALLOW_DIRTY_BUILD=1)
3. 以 <hash 前 N 位> 作为镜像 tag(默认 N=IMAGE_TAG_LENGTH=8)
4. 若同名 tag 镜像已存在且 revision 一致: 跳过构建(no-op)
除非设 FORCE_REBUILD=1
5. 否则执行 docker build, 注入 hash 到 org.opencontainers.image.revision label
相关环境变量:
CODEBUDDY2API_GIT_COMMIT_HASH ALLOW_DIRTY_BUILD ALLOW_UNVERSIONED_BUILD
FORCE_REBUILD IMAGE_TAG IMAGE_NAME IMAGE_REPOSITORY IMAGE_TAG_LENGTH
EOF
}
help_deploy() {
cat <<'EOF'
deploy — 构建并启动(一步到位)
用法:
./run_docker_in_server.sh deploy
行为:
等同 build + start + 清理旧镜像。
完整流程:
构建(同 build) → 预检 HOST_PORT 是否被占用(优先 lsof, 回退查容器端口)
→ 启动容器(bind mount 持久化目录) → 轮询 /health 等待就绪(超时 60s)
→ 清理旧镜像(保留最近 IMAGE_RETENTION_COUNT 个, 默认 5;
受保护镜像一律跳过且不占名额)
重复 deploy 同一 commit:
若镜像已存在且 revision 一致, 跳过构建, 直接复用该镜像重启容器。
适合"只改了配置、没改代码"的场景。设 FORCE_REBUILD=1 可强制重建。
副作用:
- 首次生成 data/config/config.yaml(从 config.example.yaml), 并把
server.host 改为 0.0.0.0(除非 SKIP_CONFIG_ADAPT=1)
- 删除同名旧容器后重新创建
- 清理超出保留数量的旧镜像
相关环境变量:
全部构建变量(见 build) + HOST_PORT CONTAINER_NAME CONTAINER_PORT
DATA_ROOT IMAGE_RETENTION_COUNT SKIP_CONFIG_ADAPT
STARTUP_READY_* (就绪探测相关)
EOF
}
help_start() {
cat <<'EOF'
start — 启动已有镜像
用法:
IMAGE_TAG=<tag> ./run_docker_in_server.sh start
IMAGE_NAME=<repo:tag> ./run_docker_in_server.sh start
行为:
用已构建好的镜像启动容器。不构建、不清理镜像。
必须指定 IMAGE_TAG 或 IMAGE_NAME。
前置条件:
- 目标镜像已存在(否则报错, 请先 deploy 构建)
- HOST_PORT 未被占用
相关环境变量:
IMAGE_TAG IMAGE_NAME HOST_PORT CONTAINER_NAME CONTAINER_PORT
DATA_ROOT SKIP_CONFIG_ADAPT STARTUP_READY_*
EOF
}
help_images() {
cat <<'EOF'
images — 列出本地镜像
用法:
./run_docker_in_server.sh images
行为:
列出指定 repository 下的本地镜像, 包含保护状态与 git revision,
便于追溯版本。输出列: IMAGE / IMAGE ID / CREATED / PROTECTED / REVISION。
相关环境变量:
IMAGE_REPOSITORY (或可解析 repository 的 IMAGE_NAME)
EOF
}
help_rm_image() {
cat <<'EOF'
rm-image — 删除镜像
用法:
IMAGE_TAG=<tag> ./run_docker_in_server.sh rm-image [--force]
行为:
删除指定 tag 的镜像。受保护镜像(见 protect)默认拒绝删除, 需 --force。
--force 删除后会自动从保护清单移除该 tag。
相关环境变量:
IMAGE_TAG IMAGE_NAME IMAGE_REPOSITORY
EOF
}
help_protect() {
cat <<'EOF'
protect <tag> — 标记版本为受保护
用法:
./run_docker_in_server.sh protect <tag>
行为:
将指定 tag 加入保护清单, 使其不被 deploy 自动清理, 且 rm-image 删除时
需 --force。清单存于仓库根 .protected-images(每行一个 tag, 跨实例共享)。
保护镜像不占用 IMAGE_RETENTION_COUNT 名额。
EOF
}
help_unprotect() {
cat <<'EOF'
unprotect <tag> — 取消保护
用法:
./run_docker_in_server.sh unprotect <tag>
行为:
将指定 tag 从保护清单移除, 恢复可被自动清理。
EOF
}
# 打印脚本用法说明(总览)
usage() {
cat <<'EOF'
CodeBuddy2API Docker 部署脚本(直接驱动 docker/podman, 不依赖 docker-compose)。
镜像 tag 取自 git commit hash 前缀, 便于版本追溯与回滚。
用法:
./run_docker_in_server.sh <command> [options]
./run_docker_in_server.sh help <command> # 查看某命令的详细帮助
命令:
build 仅构建镜像(按 git hash 打 tag), 不启动容器
deploy 构建镜像 + 启动容器 + 等待就绪 + 清理旧镜像
start 用已有镜像启动(不构建、不清理), 需 IMAGE_TAG/IMAGE_NAME
images 列出本地镜像(含保护状态与 git revision)
rm-image [--force] 删除指定镜像(受保护镜像需 --force)
protect <tag> 标记版本为受保护(防自动清理)
unprotect <tag> 取消保护
help [command] 显示本帮助, 或指定命令的详细帮助
示例:
./run_docker_in_server.sh deploy # 一键构建并启动
./run_docker_in_server.sh help deploy # 查看 deploy 详情
HOST_PORT=8112 ./run_docker_in_server.sh deploy # 指定端口
IMAGE_TAG=f68f2225 ./run_docker_in_server.sh start # 启动指定版本
EOF
print_env_overview
}
# 错误输出并退出
fail() {
echo "$1" >&2
exit 1
}
# 警告输出(不退出)
warn() {
echo "WARNING: $1" >&2
}
# 去除字符串首尾空白字符
trim_value() {
printf "%s" "$1" | sed "s/^[[:space:]]*//;s/[[:space:]]*$//"
}
# 规范化 git hash:转小写,仅接受 40 位(SHA-1)或 64 位(SHA-256)完整十六进制
normalize_git_hash() {
local raw
raw="$(trim_value "$1" | tr "[:upper:]" "[:lower:]")"
if [[ "$raw" =~ ^[0-9a-f]{40}$ || "$raw" =~ ^[0-9a-f]{64}$ ]]; then
printf "%s" "$raw"
return 0
fi
return 1
}
# 解析当前构建对应的 git commit hash:优先环境变量显式指定,其次 git HEAD,最后允许未版本化构建
resolve_git_commit_hash() {
local explicit_hash
explicit_hash="$(trim_value "${CODEBUDDY2API_GIT_COMMIT_HASH:-}")"
if [ -n "$explicit_hash" ]; then
normalize_git_hash "$explicit_hash" || fail "CODEBUDDY2API_GIT_COMMIT_HASH 必须是完整 40 或 64 位十六进制 hash。"
return
fi
local raw_hash
if raw_hash="$(git -C "$REPO_ROOT" rev-parse --verify HEAD 2>/dev/null)"; then
normalize_git_hash "$raw_hash" || fail "git rev-parse 返回了非法 commit hash: $raw_hash"
return
fi
if [ "${ALLOW_UNVERSIONED_BUILD:-}" = "1" ]; then
warn "无法读取 git commit hash,将构建未版本化镜像(tag=local)。"
printf ""
return
fi
fail "无法读取 git commit hash。请在 REPO_ROOT git 仓库内构建,或设置 CODEBUDDY2API_GIT_COMMIT_HASH,或显式设置 ALLOW_UNVERSIONED_BUILD=1。"
}
# 检查 git 工作区是否有未提交修改
check_dirty_worktree() {
if ! git -C "$REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
return
fi
local status
status="$(git -C "$REPO_ROOT" status --porcelain)"
if [ -z "$status" ]; then
return
fi
if [ "${ALLOW_DIRTY_BUILD:-}" = "1" ]; then
warn "当前 git 工作区存在未提交修改,镜像 hash 只代表 HEAD。"
return
fi
fail "当前 git 工作区存在未提交修改。请先提交/清理,或显式设置 ALLOW_DIRTY_BUILD=1。"
}
# 校验 IMAGE_RETENTION_COUNT 是否为非负整数
validate_retention_count() {
[[ "$IMAGE_RETENTION_COUNT" =~ ^[0-9]+$ ]] || fail "IMAGE_RETENTION_COUNT 必须是非负整数。"
}
# 通用校验:正整数
validate_positive_integer() {
local name="$1" value="$2"
[[ "$value" =~ ^[1-9][0-9]*$ ]] || fail "$name 必须是正整数。"
}
# 通用校验:非负整数
validate_non_negative_integer() {
local name="$1" value="$2"
[[ "$value" =~ ^[0-9]+$ ]] || fail "$name 必须是非负整数。"
}
# 校验启动探测相关配置项,并检查 curl 是否可用
validate_startup_probe_config() {
validate_non_negative_integer "STARTUP_READY_TIMEOUT_SECONDS" "$STARTUP_READY_TIMEOUT_SECONDS"
validate_positive_integer "STARTUP_READY_INTERVAL_SECONDS" "$STARTUP_READY_INTERVAL_SECONDS"
validate_positive_integer "STARTUP_READY_REQUEST_TIMEOUT_SECONDS" "$STARTUP_READY_REQUEST_TIMEOUT_SECONDS"
if ! command -v curl >/dev/null 2>&1; then
fail "缺少 curl,无法在清理旧镜像前确认服务就绪。请安装 curl。"
fi
}
# 校验 IMAGE_TAG_LENGTH:必须整数,有 hash 时介于 6 到 hash 长度之间
validate_image_tag_length() {
local hash_length="${#GIT_COMMIT_HASH}"
[[ "$IMAGE_TAG_LENGTH" =~ ^[0-9]+$ ]] || fail "IMAGE_TAG_LENGTH 必须是整数。"
[ "$hash_length" -eq 0 ] && return
if [ "$IMAGE_TAG_LENGTH" -lt 6 ] || [ "$IMAGE_TAG_LENGTH" -gt "$hash_length" ]; then
fail "IMAGE_TAG_LENGTH 必须在 6 到 $hash_length 之间。"
fi
}
# 判断镜像名称是否包含 tag(冒号分隔)
image_name_has_tag() {
local last_component="${1##*/}"
[[ "$last_component" == *:* ]]
}
# 解析最终镜像全名(repository:tag)
resolve_image_name() {
local default_tag="$1"
if [ "$IMAGE_NAME_WAS_SET" = "1" ]; then
if ! image_name_has_tag "$IMAGE_NAME"; then
fail "显式 IMAGE_NAME 必须包含 tag,例如 IMAGE_NAME=codebuddy2api:<git-hash>;不允许依赖 Docker 隐式 latest。"
fi
if [ "$IMAGE_TAG_WAS_SET" = "1" ] || [ "$IMAGE_REPOSITORY_WAS_SET" = "1" ]; then
warn "已显式设置 IMAGE_NAME=$IMAGE_NAME,IMAGE_REPOSITORY/IMAGE_TAG 将被忽略。"
fi
else
local resolved_tag="${IMAGE_TAG:-$default_tag}"
[ -z "$resolved_tag" ] && fail "当前命令必须设置 IMAGE_TAG 或 IMAGE_NAME。"
IMAGE_NAME="${IMAGE_REPOSITORY}:${resolved_tag}"
fi
if [[ "$IMAGE_NAME" == *":latest" ]]; then
warn "正在使用 latest tag,默认 hash tag 追溯能力将被覆盖。"
fi
}
# 从完整镜像名提取 repository(去掉 tag)
image_repository_from_name() {
local image_name="$1"
[ -z "$image_name" ] && return 1
[[ "$image_name" == *@* ]] && return 1
local last_component="${image_name##*/}"
if [[ "$last_component" == *:* ]]; then
printf "%s" "${image_name%:*}"
return 0
fi
printf "%s" "$image_name"
}
# 为 images 子命令解析 repository
repository_for_images_command() {
if [ "$IMAGE_NAME_WAS_SET" = "1" ]; then
image_repository_from_name "$IMAGE_NAME" || return 1
return
fi
printf "%s" "$IMAGE_REPOSITORY"
}
# 检查镜像是否在本地存在
docker_image_exists() {
"$DOCKER_BIN" image inspect "$1" >/dev/null 2>&1
}
# 读取镜像的 org.opencontainers.image.revision label
docker_image_revision() {
local revision
if ! revision="$("$DOCKER_BIN" image inspect --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}' "$1" 2>/dev/null)"; then
return 1
fi
[ -z "$revision" ] || [ "$revision" = "<no value>" ] && return 1
printf "%s" "$revision"
}
# 读取镜像创建时间
docker_image_created() {
"$DOCKER_BIN" image inspect --format '{{.Created}}' "$1" 2>/dev/null || true
}
# 确保 deploy 目标镜像可安全构建:若已存在,校验 revision label
ensure_deploy_target_can_be_built() {
if ! docker_image_exists "$IMAGE_NAME"; then
return
fi
local existing_revision
if ! existing_revision="$(docker_image_revision "$IMAGE_NAME")"; then
fail "目标镜像 $IMAGE_NAME 已存在,但缺少 org.opencontainers.image.revision label;请换 tag、先 rm-image,或调大 IMAGE_TAG_LENGTH。"
fi
if [ "$existing_revision" = "$GIT_COMMIT_HASH" ]; then
if [ "${FORCE_REBUILD:-}" = "1" ]; then
warn "目标镜像 $IMAGE_NAME 已存在且 revision 相同,将按 FORCE_REBUILD=1 重新构建并覆盖该 tag。"
return
fi
# Same commit already built: skip the build and reuse the image.
# deploy will proceed to start the container; a bare `build` is a no-op.
echo "镜像已存在且 revision 相同,跳过构建: $IMAGE_NAME"
SKIP_BUILD=1
return
fi
fail "目标镜像 $IMAGE_NAME 已存在,但 revision=$existing_revision 与当前 hash=$GIT_COMMIT_HASH 不一致。请调大 IMAGE_TAG_LENGTH、换 tag,或先 rm-image。"
}
# --- 受保护镜像清单 ---
# 判断指定 tag 是否受保护(清单中存在该行)。返回 0=受保护,1=未保护。
is_protected() {
local tag="$1"
[ -f "$PROTECTED_IMAGES_FILE" ] || return 1
grep -Fxq -- "$tag" "$PROTECTED_IMAGES_FILE" 2>/dev/null
}
# 将 tag 加入保护清单(去重)。
protect_add() {
local tag="$1"
mkdir -p "$(dirname "$PROTECTED_IMAGES_FILE")"
touch "$PROTECTED_IMAGES_FILE"
if is_protected "$tag"; then
echo "镜像 $IMAGE_REPOSITORY:$tag 已在保护清单中。"
return
fi
printf '%s\n' "$tag" >> "$PROTECTED_IMAGES_FILE"
echo "已保护镜像 $IMAGE_REPOSITORY:$tag(不会被自动清理,rm-image 需 --force)。"
}
# 从保护清单移除 tag。
protect_remove() {
local tag="$1"
if [ ! -f "$PROTECTED_IMAGES_FILE" ] || ! is_protected "$tag"; then
echo "镜像 $IMAGE_REPOSITORY:$tag 不在保护清单中。"
return
fi
# 用 grep -v 精确匹配行删除,写回临时文件避免 sed -i 跨平台差异。
local tmp
tmp="$(mktemp)"
grep -Fxv -- "$tag" "$PROTECTED_IMAGES_FILE" > "$tmp" 2>/dev/null || true
if [ -s "$tmp" ]; then
mv "$tmp" "$PROTECTED_IMAGES_FILE"
else
rm -f "$tmp" "$PROTECTED_IMAGES_FILE"
fi
echo "已取消保护 $IMAGE_REPOSITORY:$tag。"
}
# 清理本地旧镜像:保留最近 retention_count 个(按创建时间),删除其余同 repository 镜像。
# 受保护 tag(在 .protected-images 中)一律跳过,且不占用 retention 名额。
cleanup_old_images() {
local image_name="$1" retention_count="$2"
if [ "$retention_count" = "0" ]; then
echo "跳过本地镜像清理: IMAGE_RETENTION_COUNT=0"
return
fi
local repository
if ! repository="$(image_repository_from_name "$image_name")" || [ -z "$repository" ]; then
warn "无法从 IMAGE_NAME=$image_name 安全解析 repository,跳过本地镜像清理。"
return
fi
local image_list_file sorted_file
image_list_file="$(mktemp)"
sorted_file="$(mktemp)"
trap 'rm -f "$image_list_file" "$sorted_file"; trap - RETURN' RETURN
if ! "$DOCKER_BIN" image ls "$repository" --format '{{.Repository}}\t{{.Tag}}\t{{.ID}}' > "$image_list_file"; then
warn "无法列出 repository=$repository 的本地镜像,跳过清理。"
return
fi
local repo tag image_id created
while IFS=$'\t' read -r repo tag image_id; do
[ -z "$repo" ] || [ -z "$tag" ] || [ -z "$image_id" ] || [ "$tag" = "<none>" ] && continue
if ! created="$("$DOCKER_BIN" image inspect --format '{{.Created}}' "$image_id" 2>/dev/null)" || [ -z "$created" ]; then
continue
fi
printf "%s\t%s:%s\n" "$created" "$repo" "$tag" >> "$sorted_file"
done < "$image_list_file"
local index=0 image_ref tag
while IFS=$'\t' read -r created image_ref; do
[ -z "$image_ref" ] && continue
# 提取 tag(repo:tag -> tag)
tag="${image_ref##*:}"
# 受保护镜像一律跳过,且不占用 retention 名额。
if is_protected "$tag"; then
echo "跳过受保护镜像: $image_ref"
continue
fi
index=$((index + 1))
[ "$index" -le "$retention_count" ] && continue
if "$DOCKER_BIN" image rm "$image_ref"; then
echo "已删除旧镜像: $image_ref"
else
warn "删除旧镜像失败,可能仍被其他容器引用: $image_ref"
fi
done < <(sort -r "$sorted_file")
rm -f "$image_list_file" "$sorted_file"
trap - RETURN
}
# 适配容器配置:确保 config.yaml 存在,并把 server.host 改为 0.0.0.0(容器需监听全部接口)
adapt_container_config() {
local config_dir="$DATA_ROOT/config"
local config_file="$config_dir/config.yaml"
mkdir -p "$config_dir"
# 若不存在,从仓库模板拷贝一份
if [ ! -f "$config_file" ]; then
if [ -f "$REPO_ROOT/config.example.yaml" ]; then
cp "$REPO_ROOT/config.example.yaml" "$config_file"
echo "已从模板生成 $config_file,请编辑设置 server.password 等值。"
else
fail "缺少 $REPO_ROOT/config.example.yaml 模板,无法生成容器配置。"
fi
fi
if [ "${SKIP_CONFIG_ADAPT:-}" = "1" ]; then
warn "SKIP_CONFIG_ADAPT=1,跳过自动修改 server.host。请确保 config.yaml 中 host=0.0.0.0,否则容器外无法访问。"
return
fi
# 用 sed 把 server.host 改为 0.0.0.0。匹配 server 段下缩进的 host: 行。
# 仅替换第一个匹配(awk 精确处理 server 段)。
if ! awk '
BEGIN { in_server=0; changed=0 }
/^[[:space:]]*#/ { print; next }
/^[[:space:]]*server:[[:space:]]*$/ { in_server=1; print; next }
/^[[:space:]]*[A-Za-z_]+:[[:space:]]*$/ { if (in_server && !changed) in_server=0; print; next }
in_server && !changed && /^[[:space:]]*host:[[:space:]]*/ {
sub(/host:[[:space:]]*.*/, "host: 0.0.0.0")
changed=1
}
{ print }
' "$config_file" > "$config_file.tmp"; then
rm -f "$config_file.tmp"
warn "自动修改 server.host 失败,请手动确保 config.yaml 中 server.host=0.0.0.0。"
return
fi
mv "$config_file.tmp" "$config_file"
}
# 检查容器是否运行
container_is_running() {
local running
running="$("$DOCKER_BIN" inspect --format '{{.State.Running}}' "$1" 2>/dev/null)" || return 1
[ "$running" = "true" ]
}
# 打印容器最近 80 条日志到 stderr(诊断用)
print_container_logs() {
[ -z "$1" ] && return
echo "最近容器日志:" >&2
"$DOCKER_BIN" logs --tail 80 "$1" >&2 || warn "无法读取容器日志: $1"
}
# 等待容器内服务就绪:轮询 STARTUP_READY_URL,超时或容器退出则失败
wait_for_startup_ready() {
local container_id="$1"
local deadline=$((SECONDS + STARTUP_READY_TIMEOUT_SECONDS))
while true; do
if ! container_is_running "$container_id"; then
print_container_logs "$container_id"
fail "容器未保持 running 状态,跳过旧镜像清理。"
fi
if curl -fsS --max-time "$STARTUP_READY_REQUEST_TIMEOUT_SECONDS" "$STARTUP_READY_URL" >/dev/null 2>&1; then
echo "服务已就绪: $STARTUP_READY_URL"
return
fi
if [ "$SECONDS" -ge "$deadline" ]; then
print_container_logs "$container_id"
fail "等待服务就绪超时: $STARTUP_READY_URL。跳过旧镜像清理。"
fi
sleep "$STARTUP_READY_INTERVAL_SECONDS"
done
}
# 检测宿主机端口是否被占用。优先 lsof,回退到 "$DOCKER_BIN" ps(只能查容器占用)。
# 返回 0=端口空闲,1=被占用(占用者信息已打印到 stderr)。
host_port_is_free() {
local port="$1"
local holder=""
# 优先 lsof:能查到任意进程(不仅是容器)的占用。
if command -v lsof >/dev/null 2>&1; then
holder="$(lsof -nP -iTCP:"$port" -sTCP:LISTEN 2>/dev/null | awk 'NR>1 {print $1, "(PID "$2")"; exit}')"
if [ -n "$holder" ]; then
echo "端口 $port 已被占用: $holder" >&2
return 1
fi
return 0
fi
# 回退:用 "$DOCKER_BIN" ps 查是否有容器映射了该端口。
holder="$("$DOCKER_BIN" ps --format '{{.Names}} {{.Ports}}' 2>/dev/null | awk -v p=":$port->" '$0 ~ p {print $1; exit}')"
if [ -n "$holder" ]; then
echo "端口 $port 已被容器占用: $holder" >&2
return 1
fi
return 0
}
# 启动 Docker 容器
start_container() {
local require_image_check="${1:-1}"
validate_startup_probe_config
if [ "$require_image_check" = "1" ] && ! docker_image_exists "$IMAGE_NAME"; then
fail "镜像不存在: $IMAGE_NAME。请先 deploy 构建,或传入已有 IMAGE_TAG/IMAGE_NAME。"
fi
# 准备宿主机持久化目录
mkdir -p "$DATA_ROOT/creds" "$DATA_ROOT/logs"
adapt_container_config
# 删除同名旧容器(按容器名精确匹配,不影响其他实例)
if [ "$("$DOCKER_BIN" ps -aq -f "name=^/${CONTAINER_NAME}$")" ]; then
"$DOCKER_BIN" rm -f "$CONTAINER_NAME"
fi
# 启动前检测端口:删除同名旧容器后再查,避免误报自己之前的占用。
if ! host_port_is_free "$HOST_PORT"; then
echo "如需多版本并存,请用不同的 HOST_PORT 和 CONTAINER_NAME,例如:" >&2
echo " HOST_PORT=8112 CONTAINER_NAME=codebuddy2api-v2 ./run_docker_in_server.sh start" >&2
fail "端口 $HOST_PORT 被占用,无法启动容器 $CONTAINER_NAME。"
fi
local run_output
if ! run_output="$(
"$DOCKER_BIN" run -d \
--name "$CONTAINER_NAME" \
-p "${HOST_PORT}:${CONTAINER_PORT}" \
-v "$DATA_ROOT/config:/app/config" \
-v "$DATA_ROOT/creds:/app/.codebuddy_creds" \
-v "$DATA_ROOT/logs:/app/logs" \
"$IMAGE_NAME" 2>&1
)"; then
printf "%s\n" "$run_output" >&2
print_container_logs "$CONTAINER_NAME"
fail ""$DOCKER_BIN" run 失败,跳过旧镜像清理。"
fi
local container_id
container_id="$(trim_value "$run_output")"
wait_for_startup_ready "$container_id"
echo "CodeBuddy2API 已启动: http://127.0.0.1:${HOST_PORT}"
echo " OpenAI: http://127.0.0.1:${HOST_PORT}/codebuddy/v1"
echo " Anthropic: http://127.0.0.1:${HOST_PORT}/codebuddy"
}
# 构建:解析 hash、校验、打 tag、"$DOCKER_BIN" build。供 build/deploy 复用。
build_image() {
cd "$REPO_ROOT"
GIT_COMMIT_HASH="$(resolve_git_commit_hash)"
check_dirty_worktree
validate_retention_count
validate_image_tag_length
local default_tag
if [ -n "$GIT_COMMIT_HASH" ]; then
default_tag="${GIT_COMMIT_HASH:0:$IMAGE_TAG_LENGTH}"
else
default_tag="local"
fi
resolve_image_name "$default_tag"
SKIP_BUILD=0
ensure_deploy_target_can_be_built
if [ "$SKIP_BUILD" = "1" ]; then
return
fi
"$DOCKER_BIN" build \
-f Dockerfile \
--build-arg "${BUILD_ARG_NAME}=${GIT_COMMIT_HASH}" \
-t "$IMAGE_NAME" \
.
echo "镜像构建完成: $IMAGE_NAME (revision=${GIT_COMMIT_HASH:-local})"
}
# build 子命令:仅构建镜像,不启动容器、不清理旧镜像
command_build() {
build_image
}
# deploy 子命令:构建镜像并启动,清理旧镜像
command_deploy() {
build_image
start_container 0
cleanup_old_images "$IMAGE_NAME" "$IMAGE_RETENTION_COUNT"
}
# start 子命令:用已有镜像启动(不构建、不清理)
command_start() {
resolve_image_name ""
start_container 1
}
# images 子命令:列出 repository 下的本地镜像(含 revision)
command_images() {
local repository
if ! repository="$(repository_for_images_command)" || [ -z "$repository" ]; then
fail "无法解析镜像 repository。请设置 IMAGE_REPOSITORY,或传入可解析的 IMAGE_NAME。"
fi
printf "%-55s %-14s %-28s %-12s %s\n" "IMAGE" "IMAGE ID" "CREATED" "PROTECTED" "REVISION"
local repo tag image_id created revision protected
while IFS=$'\t' read -r repo tag image_id; do
[ -z "$repo" ] || [ -z "$tag" ] || [ -z "$image_id" ] || [ "$tag" = "<none>" ] && continue
created="$(docker_image_created "$image_id")"
revision="$(docker_image_revision "$image_id" || true)"
if is_protected "$tag"; then protected="yes"; else protected="-"; fi
printf "%-55s %-14s %-28s %-12s %s\n" "$repo:$tag" "$image_id" "$created" "$protected" "${revision:-<none>}"
done < <("$DOCKER_BIN" image ls "$repository" --format '{{.Repository}}\t{{.Tag}}\t{{.ID}}')
}
# rm-image 子命令:删除指定镜像。受保护镜像需 --force(通过 RM_FORCE=1 或参数传入)。
command_rm_image() {
resolve_image_name ""
local tag="${IMAGE_NAME##*:}"
if is_protected "$tag" && [ "${RM_FORCE:-0}" != "1" ]; then
echo "镜像 $IMAGE_NAME 受保护,不会被自动清理。" >&2
echo "如需删除,请先 unprotect,或使用: rm-image --force" >&2
fail "拒绝删除受保护镜像: $IMAGE_NAME"
fi
if "$DOCKER_BIN" image rm "$IMAGE_NAME"; then
# --force 删除后同步从保护清单移除,保持一致。
if [ "${RM_FORCE:-0}" = "1" ] && is_protected "$tag"; then
protect_remove "$tag"
fi
echo "已删除镜像: $IMAGE_NAME"
return
fi
fail "删除镜像失败,可能仍被容器引用: $IMAGE_NAME"
}
# protect 子命令:将指定 tag 加入保护清单,使其不被自动清理。
# 用法: protect <tag>
command_protect() {
local tag="${1:-}"
[ -n "$tag" ] || fail "用法: protect <tag>(如 protect f68f2225)"
if ! docker_image_exists "${IMAGE_REPOSITORY}:${tag}"; then
fail "镜像不存在: ${IMAGE_REPOSITORY}:${tag}。请先 build/deploy,或检查 tag。"
fi
protect_add "$tag"
}
# unprotect 子命令:将指定 tag 从保护清单移除。
# 用法: unprotect <tag>
command_unprotect() {
local tag="${1:-}"
[ -n "$tag" ] || fail "用法: unprotect <tag>(如 unprotect f68f2225)"
protect_remove "$tag"
}
# 无子命令时打印用法
if [ -z "$COMMAND" ]; then
usage
exit 2
fi
# 除 help 外,所有命令都需要 docker/podman 可执行文件。
# shell alias 不被子进程继承,故这里检查 DOCKER_BIN 指向的真实二进制。
if [ "$COMMAND" != "help" ] && [ "$COMMAND" != "-h" ] && [ "$COMMAND" != "--help" ]; then
if ! command -v "$DOCKER_BIN" >/dev/null 2>&1; then
fail "找不到 Docker 可执行文件: $DOCKER_BIN。请安装 docker/podman,或设置 DOCKER_BIN=<路径>(如 DOCKER_BIN=podman)。"
fi
fi
# 子命令分发
case "$COMMAND" in
help | -h | --help)
shift
sub="${1:-}"
if [ -z "$sub" ]; then
usage
exit 0
fi
case "$sub" in
build) help_build ;;
deploy) help_deploy ;;
start) help_start ;;
images) help_images ;;
rm-image) help_rm_image ;;
protect) help_protect ;;
unprotect) help_unprotect ;;
help|-h|--help) usage ;;
*)
usage
fail "help 后未知命令: $sub"
;;
esac
exit 0
;;
build | deploy | start | images)
shift
if [ "$#" -gt 0 ]; then
usage
fail "不支持额外参数: $*"
fi
"command_${COMMAND//-/_}"
;;
rm-image)
shift
# 可选 --force:允许删除受保护镜像。
RM_FORCE=0
if [ "${1:-}" = "--force" ]; then
RM_FORCE=1
shift
fi
if [ "$#" -gt 0 ]; then
usage
fail "不支持额外参数: $*"
fi
command_rm_image
;;
protect | unprotect)
shift
[ "$#" -eq 1 ] || { usage; fail "用法: $COMMAND <tag>"; }
"command_${COMMAND}" "$1"
;;
*)
usage
fail "未知命令: $COMMAND"
;;
esac