feat: 将更多 Docker 命令改为内置实现,统一按名称/ID 解析目标
- exec/rm/rmi/stop/restart 支持名称、ID 或唯一 ID 前缀,目标不存在或 前缀有歧义时在调用 docker 前报错;exec 默认 -it 并以 -- 分隔命令 - logs 新增 --since/--grep/--save;df 改为按占用排序的可读排行; stats 默认单次快照,无参数时统计全部运行中容器 - 破坏性确认:Command.ConfirmBuiltin 提供将删除对象的清单, rm/rmi 无 --yes 时列清单并退出码 2,拒绝 -f,运行中容器提示先 stop - 抽出 executor/dockertargets.go 统一镜像/容器解析与 docker 调用
This commit is contained in:
@@ -52,7 +52,7 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath \
|
||||
kopsc docker ps # 列出运行中的容器
|
||||
kopsc docker psall # 含已停止的容器
|
||||
kopsc docker logs -f web # 查看日志(容器名必填,选项可透传)
|
||||
kopsc docker exec -it web sh # 进入容器
|
||||
kopsc docker exec web sh # 进入容器(默认就是交互模式,无需 -it)
|
||||
kopsc docker compose up # 启动 Compose 服务
|
||||
kopsc docker clearall --yes # 深度清理(破坏性命令,必须显式 --yes)
|
||||
kopsc docker save alpine:3.20 # 自动保存为 alpine_3.20.tar(传镜像 ID 会先解析出名称)
|
||||
@@ -135,8 +135,8 @@ $ kopsc disk usage /var
|
||||
- 所有 Docker 命令在三个平台都调用 `docker`(macOS / Windows 上即 Docker Desktop)。
|
||||
- `logs`、`inspect`、`start`、`stop`、`restart`、`rm`、`rmi`、`exec`、`pull`、`tag`、`save`、`load`、`cp`
|
||||
需要位置参数,缺失时直接报错并给出用法。
|
||||
- 破坏性命令只有 `clearall`(`system prune -a -f`)与 `prunevolumes`(`volume prune -f`):
|
||||
未带 `--yes` 时只打印影响范围与将执行的命令,并以退出码 2 结束。
|
||||
- 破坏性命令是 `rm`、`rmi`、`clearall`(`system prune -a -f`)与 `prunevolumes`(`volume prune -f`):
|
||||
未带 `--yes` 时先列出将删除的对象、再打印将执行的命令,并以退出码 2 结束。
|
||||
- `docker compose` 对应 Compose v2。
|
||||
- `docker clearsafe` 的安全契约:只加 `-f` 以便无人值守执行,**不**加 `-a`(不清理全部未使用镜像),
|
||||
**不**加 `--volumes`(不删除卷),仅清理停止的容器、未使用的网络与悬空镜像。
|
||||
@@ -162,6 +162,41 @@ kopsc docker load a.tar b.tar # 一次导入多个
|
||||
- `docker load` 的归档文件是**位置参数**(对应原来的 `docker load < xxx.tar`),可一次给出多个;
|
||||
`-i/--input` 仍然可用,传 `-` 仍表示从标准输入读取。
|
||||
|
||||
### 容器与镜像的简写(exec / rm / rmi / stop / restart)
|
||||
|
||||
这几条命令按 kopsc 的意图做了解析,不再要求手写 docker 的细节:
|
||||
|
||||
```bash
|
||||
kopsc docker exec web sh # 等价 docker exec -it web -- sh(-it 已是默认)
|
||||
kopsc docker exec -u 0 web id # 容器名之前的 -u/-w/-e 照常透传给 docker
|
||||
kopsc docker stop 3f2a1b # 按 ID 前缀找到容器并停止
|
||||
kopsc docker rm db # 删除已停止的容器;运行中的会提示先 stop
|
||||
kopsc docker rmi sha256:5ca7b1d5e2fc # 按 ID 或唯一前缀删除镜像
|
||||
```
|
||||
|
||||
- 容器可按名称、ID 或唯一 ID 前缀定位;镜像可按名称、ID、`sha256:ID` 或唯一前缀定位。
|
||||
目标不存在或前缀有歧义时,kopsc 直接报错,不会调用 docker。
|
||||
- `exec` 默认交互,命令之后的参数一律作为容器内命令,因此命令自己的 `-i`、`-p` 不会被当成 docker 选项;
|
||||
显式传 `-i`/`-t`/`-it` 会报错提示「已默认交互」。
|
||||
- `rm`/`rmi` 属于破坏性命令:不带 `--yes` 时先列出将删除的对象(容器显示 ID/名称/镜像/状态,
|
||||
镜像显示 镜像/ID/创建/大小)再以退出码 2 结束;不再接受 `-f`,强删请用 `--yes`。
|
||||
- `rm` 遇到运行中的容器会提示先 `kopsc docker stop <名称>`,不会替你强删。
|
||||
|
||||
### 日志、占用与实时状态(logs / df / stats)
|
||||
|
||||
```bash
|
||||
kopsc docker logs web --since 2h # 只看最近 2 小时
|
||||
kopsc docker logs --grep ERROR web # 只看匹配行(kopsc 选项写在容器名之前)
|
||||
kopsc docker logs --save /var/log/web.log web -f # 同时落盘,-f 仍交给 docker
|
||||
kopsc docker df # 按占用大小排序的类型排行
|
||||
kopsc docker stats # 单次快照,默认统计全部运行中容器
|
||||
kopsc docker stats web --format '{{.CPUPerc}}'
|
||||
```
|
||||
|
||||
- `--since`/`--grep`/`--save` 是 kopsc 私有选项,必须写在容器名之前;容器名之后的参数原样交给 docker。
|
||||
- `df` 输出「类型 / 项 / 可清理 / 大小 / 可回收」,末尾附总计与可回收提示。
|
||||
- `stats` 总是单次快照(`--no-stream`),不会再刷屏;给容器名时只统计这些,且要求容器正在运行。
|
||||
|
||||
### 容器/Pod 诊断(diagnose)
|
||||
|
||||
`kopsc diagnose <名称>` 自动判断目标是 Docker 容器还是 Kubernetes Pod,输出状态、根因、依据、建议,并附最近日志与事件。只读,不修改任何对象。
|
||||
|
||||
+17
-6
@@ -152,9 +152,7 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command {
|
||||
plan := executor.Plan{Result: result, ExtraArgs: extra}
|
||||
|
||||
if cmd.RequiresYes && !yes && !dryRun {
|
||||
a.printConfirmation(cmd, plan)
|
||||
a.exitCode = 2
|
||||
return nil
|
||||
return a.printConfirmation(cmd, plan)
|
||||
}
|
||||
|
||||
return plan.Run(context.Background(), executor.Options{
|
||||
@@ -168,14 +166,27 @@ func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command {
|
||||
return leaf
|
||||
}
|
||||
|
||||
// printConfirmation explains a destructive command and how to approve it.
|
||||
func (a *App) printConfirmation(cmd *registry.Command, plan executor.Plan) {
|
||||
// printConfirmation explains a destructive command and how to approve it. If
|
||||
// the command has a preview builtin, the objects it would touch are listed
|
||||
// first (and an unknown target is reported before asking for --yes).
|
||||
func (a *App) printConfirmation(cmd *registry.Command, plan executor.Plan) error {
|
||||
if err := executor.Preview(context.Background(), plan, a.Stdout); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
w := a.Stdout
|
||||
fmt.Fprintln(w, "这是破坏性命令,未执行。")
|
||||
fmt.Fprintf(w, "命令: %s\n", cmd.Invocation())
|
||||
fmt.Fprintf(w, "将执行: %s\n", plan.Display())
|
||||
fmt.Fprintf(w, "影响: %s\n\n", cmd.Action)
|
||||
fmt.Fprintf(w, "确认执行请追加 --yes:\n %s --yes\n", cmd.Invocation())
|
||||
// Repeat the caller's own arguments so the suggestion is copy-pasteable.
|
||||
suggested := cmd.Invocation() + " --yes"
|
||||
if len(plan.ExtraArgs) > 0 {
|
||||
suggested += " " + strings.Join(plan.ExtraArgs, " ")
|
||||
}
|
||||
fmt.Fprintf(w, "确认执行请追加 --yes:\n %s\n", suggested)
|
||||
a.exitCode = 2
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseLeafArgs splits leaf arguments: kopsc-level flags before the first `--`
|
||||
|
||||
@@ -631,3 +631,105 @@ func TestDockerLoadThroughCLIRequiresArchive(t *testing.T) {
|
||||
t.Errorf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// cliRuntime installs a fake docker answering both `images` and `ps`, and
|
||||
// returns the path of the invocation log.
|
||||
func cliRuntime(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
script := "#!/bin/sh\n" +
|
||||
"echo \"$*\" >> " + logPath + "\n" +
|
||||
"case \"$1\" in\n" +
|
||||
" images) cat <<'EOT'\nIMAGE ID\tREPOSITORY\tTAG\tDIGEST\n9c6f07244728\talpine\t3.20\t\nEOT\n" +
|
||||
" ;;\n" +
|
||||
" ps) cat <<'EOT'\n3f2a1b0c9d8e\tweb\tnginx:1.25\tUp 2 hours\nEOT\n" +
|
||||
" ;;\n" +
|
||||
"esac\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
|
||||
return logPath
|
||||
}
|
||||
|
||||
func TestDockerExecThroughCLIDefaultsToInteractive(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake runtime is a shell script")
|
||||
}
|
||||
logPath := cliRuntime(t)
|
||||
|
||||
app, _, stderr := testApp(centos("docker"))
|
||||
if code := app.Execute([]string{"docker", "exec", "web", "sh", "-c", "echo hi"}); code != 0 {
|
||||
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
data, _ := os.ReadFile(logPath)
|
||||
if !strings.Contains(string(data), "exec -it web -- sh -c echo hi") {
|
||||
t.Errorf("docker calls = %q, want the -it/-- form", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecRejectsDashItThroughCLI(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake runtime is a shell script")
|
||||
}
|
||||
cliRuntime(t)
|
||||
|
||||
app, _, stderr := testApp(centos("docker"))
|
||||
if code := app.Execute([]string{"docker", "exec", "-it", "web", "sh"}); code == 0 {
|
||||
t.Fatal("expected a non-zero exit code for an explicit -it")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "已默认以交互方式") {
|
||||
t.Errorf("stderr = %q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerStopThroughCLIResolvesContainerName(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake runtime is a shell script")
|
||||
}
|
||||
logPath := cliRuntime(t)
|
||||
|
||||
app, _, stderr := testApp(centos("docker"))
|
||||
if code := app.Execute([]string{"docker", "stop", "3f2a1b"}); code != 0 {
|
||||
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
data, _ := os.ReadFile(logPath)
|
||||
if !strings.Contains(string(data), "stop web") {
|
||||
t.Errorf("docker calls = %q, want `stop web`", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRMWithoutYesListsTargetsAndExitsTwo(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("fake runtime is a shell script")
|
||||
}
|
||||
logPath := cliRuntime(t)
|
||||
|
||||
app, stdout, stderr := testApp(centos("docker"))
|
||||
if code := app.Execute([]string{"docker", "rm", "web"}); code != 2 {
|
||||
t.Fatalf("exit code = %d, want 2; stderr=%s", code, stderr.String())
|
||||
}
|
||||
out := stdout.String()
|
||||
for _, want := range []string{"将影响:", "3f2a1b0c9d8e", "web", "nginx:1.25", "Up 2 hours", "这是破坏性命令", "--yes"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("confirmation missing %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
for _, call := range readCalls(logPath) {
|
||||
if strings.HasPrefix(call, "rm ") {
|
||||
t.Fatalf("docker rm must not run without --yes, calls = %v", readCalls(logPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readCalls(path string) []string {
|
||||
data, _ := os.ReadFile(path)
|
||||
var out []string
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/diagnose"
|
||||
@@ -28,8 +30,18 @@ type Builtin func(ctx context.Context, req BuiltinRequest) error
|
||||
var builtins = map[string]Builtin{
|
||||
"disk.usage": runDiskUsage,
|
||||
"diagnose": runDiagnose,
|
||||
"docker.df": runDockerDF,
|
||||
"docker.exec": runDockerExec,
|
||||
"docker.load": runDockerLoad,
|
||||
"docker.logs": runDockerLogs,
|
||||
"docker.restart": runDockerRestart,
|
||||
"docker.rm": runDockerRM,
|
||||
"docker.rm.preview": runDockerPreviewRM,
|
||||
"docker.rmi": runDockerRMI,
|
||||
"docker.rmi.preview": runDockerPreviewRMI,
|
||||
"docker.save": runDockerSave,
|
||||
"docker.stats": runDockerStats,
|
||||
"docker.stop": runDockerStop,
|
||||
"net.pids": runNetPIDs,
|
||||
"proc.nohup": runNohup,
|
||||
}
|
||||
@@ -40,6 +52,40 @@ func runDiagnose(ctx context.Context, req BuiltinRequest) error {
|
||||
return service.Run(ctx, req.Args)
|
||||
}
|
||||
|
||||
// Preview runs a command's ConfirmBuiltin (if any) and writes the list of
|
||||
// objects the command would act on. It is used for the confirmation prompt and
|
||||
// for --dry-run, so neither path needs to touch the objects themselves.
|
||||
//
|
||||
// The list is built from the container/image references kopsc resolves, which
|
||||
// means every target is validated first: an unknown target is reported here
|
||||
// rather than after the user typed --yes. Commands without a ConfirmBuiltin
|
||||
// print nothing.
|
||||
func Preview(ctx context.Context, plan Plan, w io.Writer) error {
|
||||
name := plan.PreviewBuiltin()
|
||||
if name == "" {
|
||||
return nil
|
||||
}
|
||||
fn, ok := LookupBuiltin(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("未注册的确认动作 %q", name)
|
||||
}
|
||||
// Build the list in memory first: a target that fails to resolve must not
|
||||
// leave a dangling "将影响:" heading behind.
|
||||
var buf bytes.Buffer
|
||||
if err := fn(ctx, BuiltinRequest{
|
||||
Args: plan.ExtraArgs,
|
||||
Stdout: &buf,
|
||||
Stderr: io.Discard,
|
||||
Info: plan.Result.Info,
|
||||
Plan: plan,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintln(w, "将影响:")
|
||||
_, err := w.Write(buf.Bytes())
|
||||
return err
|
||||
}
|
||||
|
||||
// LookupBuiltin returns the implementation registered under name.
|
||||
func LookupBuiltin(name string) (Builtin, bool) {
|
||||
fn, ok := builtins[name]
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
// dfRow is one row of `docker system df`: a resource type, how many exist,
|
||||
// how much space they use and how much of that is reclaimable.
|
||||
type dfRow struct {
|
||||
Type string
|
||||
Total int64
|
||||
Active int64
|
||||
Size int64
|
||||
Reclaim int64
|
||||
}
|
||||
|
||||
// runDockerDF implements `kopsc docker df`: `docker system df` rendered as a
|
||||
// readable, sorted ranking with a total and a reclaim hint.
|
||||
func runDockerDF(ctx context.Context, req BuiltinRequest) error {
|
||||
rows, err := readDockerDF(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return errors.New("docker system df 没有返回任何数据")
|
||||
}
|
||||
renderDockerDF(rows, req.Stdout)
|
||||
return nil
|
||||
}
|
||||
|
||||
func readDockerDF(ctx context.Context) ([]dfRow, error) {
|
||||
out, err := dockerOutput(ctx, "system", "df")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rows []dfRow
|
||||
for _, line := range splitLines(out) {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "TYPE") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 2 {
|
||||
continue
|
||||
}
|
||||
row := dfRow{Type: strings.TrimSpace(fields[0])}
|
||||
row.Total = parseCount(fieldAt(fields, 1))
|
||||
row.Active = parseCount(fieldAt(fields, 2))
|
||||
row.Size, _ = parseDockerSize(fieldAt(fields, 3))
|
||||
reclaimable := fieldAt(fields, 4)
|
||||
row.Reclaim, _ = parseDockerSize(reclaimable)
|
||||
rows = append(rows, row)
|
||||
}
|
||||
|
||||
// Rank by size, largest first; unknown types sort last but are kept.
|
||||
sort.SliceStable(rows, func(i, j int) bool {
|
||||
if rows[i].Size != rows[j].Size {
|
||||
return rows[i].Size > rows[j].Size
|
||||
}
|
||||
return rows[i].Type < rows[j].Type
|
||||
})
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func fieldAt(fields []string, i int) string {
|
||||
if i < len(fields) {
|
||||
return strings.TrimSpace(fields[i])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// renderDockerDF writes the ranking. The trailing "可回收" column tells the
|
||||
// user how much cleanup (kopsc docker clearsafe) would free.
|
||||
func renderDockerDF(rows []dfRow, w io.Writer) {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "类型\t项\t可清理\t大小\t可回收")
|
||||
|
||||
var totalSize, totalReclaim, totalItems, totalFree int64
|
||||
for _, row := range rows {
|
||||
free := row.Total - row.Active
|
||||
if free < 0 {
|
||||
free = 0
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%d\t%d\t%s\t%s\n",
|
||||
row.Type, row.Total, free, humanSize(row.Size), humanSize(row.Reclaim))
|
||||
totalSize += row.Size
|
||||
totalReclaim += row.Reclaim
|
||||
totalItems += row.Total
|
||||
totalFree += free
|
||||
}
|
||||
fmt.Fprintf(tw, "总计\t%d\t%d\t%s\t%s\n", totalItems, totalFree, humanSize(totalSize), humanSize(totalReclaim))
|
||||
_ = tw.Flush()
|
||||
|
||||
if totalReclaim > 0 {
|
||||
fmt.Fprintf(w, "\n可回收 %s;清理请用 kopsc docker clearsafe(不含全部未使用镜像与卷)\n", humanSize(totalReclaim))
|
||||
} else {
|
||||
fmt.Fprintln(w, "\n没有可回收的空间")
|
||||
}
|
||||
}
|
||||
|
||||
// parseCount reads an integer count such as "19" or "" (unknown).
|
||||
func parseCount(s string) int64 {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// parseDockerSize reads docker's size text back into bytes. Docker prints
|
||||
// values such as "0B", "1.234GB", "512MB", "1.5GB (25%)" or "1.234GB (2.1GB)"
|
||||
// depending on the format and version, so only the leading number and unit are
|
||||
// used. A "-" or empty value means zero.
|
||||
func parseDockerSize(s string) (int64, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" || s == "-" || s == "N/A" {
|
||||
return 0, nil
|
||||
}
|
||||
// Docker appends a percentage or a parenthesised detail; drop it. A space
|
||||
// only separates number and unit (docker prints "3 MiB" in some formats),
|
||||
// so it is removed but not used to cut.
|
||||
if i := strings.IndexByte(s, '('); i >= 0 {
|
||||
s = strings.TrimSpace(s[:i])
|
||||
}
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
|
||||
i := 0
|
||||
for i < len(s) && (s[i] >= '0' && s[i] <= '9' || s[i] == '.') {
|
||||
i++
|
||||
}
|
||||
number, unit := s[:i], strings.ToUpper(strings.TrimSpace(s[i:]))
|
||||
value, err := strconv.ParseFloat(number, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("无法解析大小 %q", s)
|
||||
}
|
||||
|
||||
multiplier := []struct {
|
||||
unit string
|
||||
base float64
|
||||
}{
|
||||
{"B", 1},
|
||||
{"KB", 1e3},
|
||||
{"MB", 1e6},
|
||||
{"GB", 1e9},
|
||||
{"TB", 1e12},
|
||||
{"PB", 1e15},
|
||||
{"KIB", 1 << 10},
|
||||
{"MIB", 1 << 20},
|
||||
{"GIB", 1 << 30},
|
||||
{"TIB", 1 << 40},
|
||||
}
|
||||
if unit == "" {
|
||||
// A bare number is bytes, as `--format {{.Size}}` may print.
|
||||
unit = "B"
|
||||
}
|
||||
for _, m := range multiplier {
|
||||
if unit == m.unit {
|
||||
return int64(value * m.base), nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("无法识别大小单位 %q", unit)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// execValueOption lists docker exec options that take a separate value, so the
|
||||
// value is not mistaken for the container name or the command.
|
||||
var execValueOption = map[string]bool{
|
||||
"u": true,
|
||||
"user": true,
|
||||
"w": true,
|
||||
"workdir": true,
|
||||
"e": true,
|
||||
"env": true,
|
||||
"env-file": true,
|
||||
"detach-keys": true,
|
||||
}
|
||||
|
||||
// runDockerExec implements `kopsc docker exec <容器> <命令...>`.
|
||||
//
|
||||
// Interactive use is the default, so `-it` is no longer typed: kopsc always
|
||||
// runs docker exec with -it and separates the command with `--`, which keeps
|
||||
// the command's own flags from being read as docker options. Passing -i/-t/-it
|
||||
// explicitly is an error, because the flag is now implied.
|
||||
//
|
||||
// Arguments before the container name are docker exec options and are
|
||||
// forwarded; everything after the container name is the command, verbatim.
|
||||
func runDockerExec(ctx context.Context, req BuiltinRequest) error {
|
||||
args := req.Args
|
||||
if err := rejectInteractiveFlags(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(args) == 0 {
|
||||
return errors.New("缺少容器;用法: kopsc docker exec <容器> <命令...>")
|
||||
}
|
||||
|
||||
var opts []string
|
||||
target := ""
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case arg == "--":
|
||||
// An explicit separator before the container name: what follows is
|
||||
// still the container, then the command.
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
target = args[i]
|
||||
return dockerRun(ctx, execArgv(opts, target, args[i+1:]...), req)
|
||||
}
|
||||
return errors.New("`--` 之后缺少容器;用法: kopsc docker exec <容器> <命令...>")
|
||||
case !strings.HasPrefix(arg, "-") || arg == "-":
|
||||
// The first positional argument is the container; the rest is the
|
||||
// command, which may itself contain flags.
|
||||
target = arg
|
||||
return dockerRun(ctx, execArgv(opts, target, args[i+1:]...), req)
|
||||
default:
|
||||
// A docker exec option; keep its value attached to it.
|
||||
opts = append(opts, arg)
|
||||
if !strings.Contains(arg, "=") && execValueOption[strings.TrimLeft(arg, "-")] {
|
||||
if i+1 >= len(args) {
|
||||
return fmt.Errorf("选项 %s 缺少参数", arg)
|
||||
}
|
||||
i++
|
||||
opts = append(opts, args[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("缺少容器;用法: kopsc docker exec <容器> <命令...>")
|
||||
}
|
||||
|
||||
// execArgv builds `exec -it <已有的 docker 选项> <容器> -- <命令...>`.
|
||||
func execArgv(opts []string, target string, command ...string) []string {
|
||||
argv := append([]string{"exec", "-it"}, opts...)
|
||||
argv = append(argv, target, "--")
|
||||
return append(argv, command...)
|
||||
}
|
||||
|
||||
// rejectInteractiveFlags refuses -i/-t/-it/--interactive/--tty: kopsc always
|
||||
// provides them.
|
||||
func rejectInteractiveFlags(args []string) error {
|
||||
for _, arg := range args {
|
||||
if arg == "--" || !strings.HasPrefix(arg, "-") || arg == "-" {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case arg == "--interactive" || arg == "--tty" || arg == "-i" || arg == "-t" || arg == "-it" || arg == "-ti":
|
||||
return errors.New("kopsc docker exec 已默认以交互方式(-it)执行,无需再传 " + arg +
|
||||
";用法: kopsc docker exec <容器> <命令...>")
|
||||
}
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
continue
|
||||
}
|
||||
// Bundled short flags such as -di still carry -i or -t.
|
||||
short := strings.TrimPrefix(arg, "-")
|
||||
if strings.ContainsRune(short, 'i') || strings.ContainsRune(short, 't') {
|
||||
return errors.New("kopsc docker exec 已默认以交互方式(-it)执行,无需再传 " + arg +
|
||||
";用法: kopsc docker exec <容器> <命令...>")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
)
|
||||
|
||||
// runDockerStop and runDockerRestart implement `docker stop` / `docker
|
||||
// restart` over kopsc-resolved containers: a name, ID or unique ID prefix is
|
||||
// mapped to the container's name before docker runs, so a typo is reported by
|
||||
// kopsc (pointing at `kopsc docker psall`) instead of by docker.
|
||||
func runDockerStop(ctx context.Context, req BuiltinRequest) error {
|
||||
return runContainerLifecycle(ctx, req, "stop", "kopsc docker stop <容器>...")
|
||||
}
|
||||
|
||||
func runDockerRestart(ctx context.Context, req BuiltinRequest) error {
|
||||
return runContainerLifecycle(ctx, req, "restart", "kopsc docker restart <容器>...")
|
||||
}
|
||||
|
||||
func runContainerLifecycle(ctx context.Context, req BuiltinRequest, subcommand, usage string) error {
|
||||
targets, passthrough, err := splitDockerTargets(req.Args, lifecycleValueOption)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return errors.New("缺少容器;用法: " + usage)
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
ref, err := resolveContainerRef(ctx, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names = append(names, ref.Names)
|
||||
}
|
||||
|
||||
args := append([]string{subcommand}, names...)
|
||||
args = append(args, passthrough...)
|
||||
return dockerRun(ctx, args, req)
|
||||
}
|
||||
|
||||
// lifecycleValueOption lists stop/restart options that take a separate value,
|
||||
// keyed the way splitDockerTargets looks them up (short and long spellings).
|
||||
var lifecycleValueOption = map[string]bool{
|
||||
"t": true,
|
||||
"time": true,
|
||||
"s": true,
|
||||
"signal": true,
|
||||
"timeout": true,
|
||||
"signals": true,
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runDockerLogs implements `kopsc docker logs <容器> [docker 选项...]`.
|
||||
//
|
||||
// `--since`, `--grep` and `--save` are kopsc's own options and must appear
|
||||
// before the container name; everything from the container name on is passed
|
||||
// to docker verbatim, so `-f`, `--tail` and friends keep working. When no
|
||||
// kopsc-only option is used the logs stream straight through; with --grep or
|
||||
// --save they are filtered and/or written to a file while still being shown.
|
||||
func runDockerLogs(ctx context.Context, req BuiltinRequest) error {
|
||||
opts, target, rest, err := parseDockerLogs(req.Args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target == "" {
|
||||
return errors.New("缺少容器;用法: kopsc docker logs <容器> [选项...]")
|
||||
}
|
||||
|
||||
args := append([]string{"logs", target}, rest...)
|
||||
if opts.grep == nil && opts.save == "" {
|
||||
return dockerRun(ctx, args, req)
|
||||
}
|
||||
return streamLogs(ctx, args, opts, req)
|
||||
}
|
||||
|
||||
// logsOptions are kopsc's private options for `docker logs`.
|
||||
type logsOptions struct {
|
||||
grep *regexp.Regexp // keep only lines matching this pattern
|
||||
save string // append the displayed log to this file
|
||||
}
|
||||
|
||||
// parseDockerLogs splits kopsc's options (before the container) from the
|
||||
// container name and the arguments forwarded to docker (after it).
|
||||
//
|
||||
// --since is accepted on either side of the container name; kopsc turns it
|
||||
// into docker's own --since, so it reaches docker exactly once.
|
||||
func parseDockerLogs(args []string) (opts logsOptions, target string, rest []string, err error) {
|
||||
i := 0
|
||||
for ; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
|
||||
if arg == "--" {
|
||||
i++
|
||||
break
|
||||
}
|
||||
if !strings.HasPrefix(arg, "-") || arg == "-" {
|
||||
break
|
||||
}
|
||||
|
||||
name, value := arg, ""
|
||||
if eq := strings.IndexByte(arg, '='); eq >= 0 && strings.HasPrefix(arg, "--") {
|
||||
name, value = arg[:eq], arg[eq+1:]
|
||||
}
|
||||
|
||||
takeValue := func() (string, error) {
|
||||
if value != "" {
|
||||
return value, nil
|
||||
}
|
||||
if i+1 >= len(args) {
|
||||
return "", fmt.Errorf("%s 缺少参数", name)
|
||||
}
|
||||
i++
|
||||
return args[i], nil
|
||||
}
|
||||
|
||||
switch name {
|
||||
case "--grep":
|
||||
pattern, err := takeValue()
|
||||
if err != nil {
|
||||
return opts, "", nil, err
|
||||
}
|
||||
re, err := regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return opts, "", nil, fmt.Errorf("--grep 的正则表达式无效: %w", err)
|
||||
}
|
||||
opts.grep = re
|
||||
case "--save":
|
||||
save, err := takeValue()
|
||||
if err != nil {
|
||||
return opts, "", nil, err
|
||||
}
|
||||
opts.save = save
|
||||
case "--since":
|
||||
since, err := takeValue()
|
||||
if err != nil {
|
||||
return opts, "", nil, err
|
||||
}
|
||||
rest = append(rest, "--since", since)
|
||||
default:
|
||||
// A docker option typed before the container name: hand it to
|
||||
// docker, keeping any separate value attached to it.
|
||||
rest = append(rest, arg)
|
||||
if value == "" && dockerOptionTakesValue(name) {
|
||||
next, err := takeValue()
|
||||
if err != nil {
|
||||
return opts, "", nil, err
|
||||
}
|
||||
rest = append(rest, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if i >= len(args) {
|
||||
return opts, "", rest, nil
|
||||
}
|
||||
return opts, args[i], append(rest, args[i+1:]...), nil
|
||||
}
|
||||
|
||||
// dockerOptionTakesValue lists docker logs options that take a separate
|
||||
// value, so the value is not mistaken for the container name.
|
||||
func dockerOptionTakesValue(arg string) bool {
|
||||
switch arg {
|
||||
case "--tail", "-n", "--since", "-s", "--until", "--details", "--timestamp":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// streamLogs runs docker logs, relaying it line by line so --grep can filter
|
||||
// and --save can capture the log while it is still displayed.
|
||||
func streamLogs(ctx context.Context, args []string, opts logsOptions, req BuiltinRequest) error {
|
||||
cmd, out, err := startDockerPipe(ctx, args, req.Stderr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var file *os.File
|
||||
if opts.save != "" {
|
||||
if file, err = openLogSink(opts.save); err != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(out)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if opts.grep != nil && !opts.grep.MatchString(line) {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(req.Stdout, line)
|
||||
if file != nil {
|
||||
if _, err := fmt.Fprintln(file, line); err != nil {
|
||||
return fmt.Errorf("写入 %q 失败: %w", opts.save, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return waitDockerPipe(cmd, scanner.Err())
|
||||
}
|
||||
|
||||
// openLogSink opens the --save file for appending, creating its directory.
|
||||
func openLogSink(path string) (*os.File, error) {
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("创建目录 %q 失败: %w", dir, err)
|
||||
}
|
||||
}
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("打开日志文件 %q 失败: %w", path, err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
// runDockerRM implements `docker rm` over kopsc-resolved targets: containers
|
||||
// are named by name or ID (or a unique ID prefix), and are resolved before
|
||||
// docker is invoked so a typo fails with kopsc's own message instead of
|
||||
// docker's.
|
||||
//
|
||||
// The `${targets}` placeholder in the command's BaseArgs is replaced with the
|
||||
// resolved names; everything the caller typed after the targets is forwarded.
|
||||
func runDockerRM(ctx context.Context, req BuiltinRequest) error {
|
||||
targets, passthrough, err := splitDockerTargets(req.Args, rmValueOption)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return errors.New("缺少容器;用法: kopsc docker rm <容器>...")
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
ref, err := resolveContainerRef(ctx, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Running() {
|
||||
return &ContainerRunningError{Name: ref.Names}
|
||||
}
|
||||
names = append(names, ref.Names)
|
||||
}
|
||||
|
||||
args := append([]string{"rm"}, names...)
|
||||
args = append(args, passthrough...)
|
||||
return dockerRun(ctx, args, req)
|
||||
}
|
||||
|
||||
// runDockerRMI implements `docker rmi` over kopsc-resolved targets: images are
|
||||
// named by name, ID, sha256:-prefixed ID or unique ID prefix. An image that is
|
||||
// only known by ID without a tag (a dangling image) is still removed — by its
|
||||
// ID, since it has no name.
|
||||
func runDockerRMI(ctx context.Context, req BuiltinRequest) error {
|
||||
targets, passthrough, err := splitDockerTargets(req.Args, rmiValueOption)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return errors.New("缺少镜像;用法: kopsc docker rmi <镜像名|镜像ID>...")
|
||||
}
|
||||
|
||||
refs, err := listImages(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
ref, err := lookupImageRef(refs, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ref.Name != "" {
|
||||
names = append(names, ref.Name)
|
||||
} else {
|
||||
names = append(names, ref.ID)
|
||||
}
|
||||
}
|
||||
|
||||
args := append([]string{"rmi"}, names...)
|
||||
args = append(args, passthrough...)
|
||||
return dockerRun(ctx, args, req)
|
||||
}
|
||||
|
||||
// ContainerRunningError reports a container that must be stopped before it
|
||||
// can be removed. docker rm refuses running containers anyway; reporting it
|
||||
// here gives the user the exact next command.
|
||||
type ContainerRunningError struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (e *ContainerRunningError) Error() string {
|
||||
return "容器 " + e.Name + " 正在运行,docker rm 不会强制删除;请先执行 kopsc docker stop " + e.Name
|
||||
}
|
||||
|
||||
// rmValueOption and rmiValueOption list options that take a separate value, so
|
||||
// the value is not mistaken for a target.
|
||||
var (
|
||||
rmValueOption = map[string]bool{}
|
||||
rmiValueOption = map[string]bool{"platform": true}
|
||||
)
|
||||
|
||||
// splitDockerTargets separates positional targets from options. Options are
|
||||
// collected in passthrough; an option that takes a value keeps its value with
|
||||
// it. A `--` ends option parsing, so a target may not start with "-".
|
||||
func splitDockerTargets(args []string, valueOptions map[string]bool) (targets, passthrough []string, err error) {
|
||||
if err := rejectForce(args, "不再接受 -f;需要强制删除请改用 --yes(kopsc 会在执行前列出将删除的对象)"); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
afterDashDash := false
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case afterDashDash:
|
||||
targets = append(targets, arg)
|
||||
case arg == "--":
|
||||
afterDashDash = true
|
||||
case !strings.HasPrefix(arg, "-") || arg == "-":
|
||||
targets = append(targets, arg)
|
||||
default:
|
||||
passthrough = append(passthrough, arg)
|
||||
if !strings.Contains(arg, "=") && valueOptions[strings.TrimLeft(arg, "-")] {
|
||||
if i+1 < len(args) {
|
||||
i++
|
||||
passthrough = append(passthrough, args[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return targets, passthrough, nil
|
||||
}
|
||||
|
||||
// rejectForce refuses docker's -f/--force up to the first `--`: kopsc shows
|
||||
// what will be deleted and takes --yes as the confirmation, so a force flag
|
||||
// would only duplicate (or, with -f, silently bypass) that contract.
|
||||
func rejectForce(args []string, message string) error {
|
||||
for _, arg := range args {
|
||||
if arg == "--" {
|
||||
return nil
|
||||
}
|
||||
// Bundled short flags such as -fv still carry the force flag.
|
||||
if arg == "--force" || arg == "--force=true" || arg == "-f" ||
|
||||
(strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") && strings.Contains(arg, "f")) {
|
||||
return errors.New(message)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runDockerPreviewRM lists the containers `docker rm` would delete: ID, name,
|
||||
// image and status, one per line. It never touches the containers, and it
|
||||
// reports an unknown target before the user is asked for --yes.
|
||||
func runDockerPreviewRM(ctx context.Context, req BuiltinRequest) error {
|
||||
targets, _, err := splitDockerTargets(req.Args, rmValueOption)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return errors.New("缺少容器;用法: kopsc docker rm <容器>...")
|
||||
}
|
||||
|
||||
refs, err := listContainers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(req.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\t名称\t镜像\t状态")
|
||||
for _, target := range targets {
|
||||
ref, err := matchContainerRef(refs, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", shortID(ref.ID), ref.Names, ref.Image, ref.Status)
|
||||
if ref.Running() {
|
||||
fmt.Fprintf(tw, "\t\t\t(运行中:需先 kopsc docker stop %s)\n", ref.Names)
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// runDockerPreviewRMI lists the images `docker rmi` would delete: name, ID,
|
||||
// creation time and size.
|
||||
func runDockerPreviewRMI(ctx context.Context, req BuiltinRequest) error {
|
||||
targets, _, err := splitDockerTargets(req.Args, rmiValueOption)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return errors.New("缺少镜像;用法: kopsc docker rmi <镜像名|镜像ID>...")
|
||||
}
|
||||
|
||||
refs, err := listImages(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tw := tabwriter.NewWriter(req.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "镜像\tID\t创建\t大小")
|
||||
for _, target := range targets {
|
||||
ref, err := lookupImageRef(refs, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := ref.Name
|
||||
if name == "" {
|
||||
name = "<none>(悬空镜像)"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", name, shortID(ref.ID), formatCreated(ref.CreatedAt), ref.Size)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// shortID trims docker's 12-character ID for display, keeping shorter IDs.
|
||||
func shortID(id string) string {
|
||||
id = strings.TrimPrefix(id, "sha256:")
|
||||
if len(id) > 12 {
|
||||
return id[:12]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// formatCreated renders docker's creation timestamp compactly.
|
||||
func formatCreated(created string) string {
|
||||
created = strings.TrimSpace(created)
|
||||
if i := strings.IndexByte(created, '.'); i > 0 && strings.Contains(created[:i], "T") == false {
|
||||
return created
|
||||
}
|
||||
// Docker prints "2024-10-01 12:34:56 +0800 CST"; drop the timezone suffix.
|
||||
if i := strings.IndexByte(created, ' '); i > 0 {
|
||||
rest := created[i+1:]
|
||||
if j := strings.IndexByte(rest, ' '); j > 0 && (strings.Contains(rest, "+") || strings.Contains(rest, "-")) {
|
||||
return created[:i+1+j]
|
||||
}
|
||||
}
|
||||
return created
|
||||
}
|
||||
@@ -5,11 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/diagnose"
|
||||
)
|
||||
|
||||
// runDockerSave implements `docker save <镜像> > <镜像名>.tar`.
|
||||
@@ -43,20 +40,20 @@ func runDockerSave(ctx context.Context, req BuiltinRequest) error {
|
||||
return err
|
||||
}
|
||||
|
||||
ref, err := resolveImageRef(ctx, diagnose.ExecRunner{}, image)
|
||||
ref, err := resolveImageRef(ctx, image)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
outPath := archiveName(ref)
|
||||
outPath := archiveName(ref.Name)
|
||||
|
||||
// Docker derives the archive's internal tags from the image itself, so the
|
||||
// caller's argument is passed through as given.
|
||||
args := append([]string{"save", "-o", outPath, image}, passthrough...)
|
||||
if err := runRuntimeCommand(ctx, args, req); err != nil {
|
||||
if err := dockerRun(ctx, args, req); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(req.Stdout, "已保存: %s\n", ref)
|
||||
fmt.Fprintf(req.Stdout, "已保存: %s\n", ref.Name)
|
||||
fmt.Fprintf(req.Stdout, "文件: %s\n", outPath)
|
||||
return nil
|
||||
}
|
||||
@@ -101,7 +98,7 @@ func runDockerLoad(ctx context.Context, req BuiltinRequest) error {
|
||||
if !stream {
|
||||
return errors.New("缺少归档文件;用法: kopsc docker load <镜像归档.tar>")
|
||||
}
|
||||
return runRuntimeCommand(ctx, []string{"load"}, req)
|
||||
return dockerRun(ctx, []string{"load"}, req)
|
||||
}
|
||||
|
||||
var paths []string
|
||||
@@ -117,7 +114,7 @@ func runDockerLoad(ctx context.Context, req BuiltinRequest) error {
|
||||
for _, path := range paths {
|
||||
args = append(args, "-i", path)
|
||||
}
|
||||
return runRuntimeCommand(ctx, args, req)
|
||||
return dockerRun(ctx, args, req)
|
||||
}
|
||||
|
||||
// saveValueOption lists docker `save` options that take a separate
|
||||
@@ -153,117 +150,6 @@ func existingArchive(archive string) (string, error) {
|
||||
return archive, nil
|
||||
}
|
||||
|
||||
// imageRef is the repository and tag (or digest) of one local image.
|
||||
type imageRef struct {
|
||||
Name string // repository[:tag][@digest], e.g. "alpine:3.20"
|
||||
ID string // image ID, e.g. "sha256:abc..."
|
||||
}
|
||||
|
||||
// customFormat lets a single `images --format` call list IDs together with
|
||||
// names (docker images -q prints IDs only).
|
||||
const customFormat = `{{.ID}}{{"\t"}}{{if .Repository}}{{.Repository}}{{"\t"}}{{.Tag}}{{"\t"}}{{.Digest}}{{end}}`
|
||||
|
||||
// resolveImageRef maps the user's argument to the image's repository[:tag].
|
||||
//
|
||||
// A name is used as given once it is confirmed locally. An image ID — with or
|
||||
// without the sha256: prefix, or a unique prefix — resolves to the name it is
|
||||
// tagged with; an untagged image is an error, because the archive would have no
|
||||
// name to take its file name from. Names always win over IDs, so a short name
|
||||
// such as "alpine" is never mistaken for an ID prefix.
|
||||
func resolveImageRef(ctx context.Context, runner diagnose.Runner, image string) (string, error) {
|
||||
refs, err := listImages(ctx, runner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, ref := range refs {
|
||||
if ref.Name == image {
|
||||
return image, nil
|
||||
}
|
||||
}
|
||||
|
||||
if !looksLikeImageID(image) {
|
||||
return "", fmt.Errorf("本地没有镜像 %q,可用 kopsc docker images 查看", image)
|
||||
}
|
||||
|
||||
// Same image ID on several rows is normal (one row per repository:tag).
|
||||
ids := map[string][]string{}
|
||||
var order []string
|
||||
for _, ref := range refs {
|
||||
id := normalizeImageID(ref.ID)
|
||||
if !strings.HasPrefix(id, normalizeImageID(image)) {
|
||||
continue
|
||||
}
|
||||
if _, seen := ids[id]; !seen {
|
||||
order = append(order, id)
|
||||
}
|
||||
ids[id] = append(ids[id], ref.Name)
|
||||
}
|
||||
|
||||
switch len(order) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("本地没有镜像 ID %q,可用 kopsc docker images 查看", image)
|
||||
case 1:
|
||||
default:
|
||||
return "", fmt.Errorf("镜像 ID %q 匹配多个本地镜像(%s),请改用镜像名称",
|
||||
image, strings.Join(order, ", "))
|
||||
}
|
||||
|
||||
names := ids[order[0]]
|
||||
if len(names) > 1 {
|
||||
return "", fmt.Errorf("镜像 ID %q 对应多个名称(%s),请直接指定要保存的名称",
|
||||
image, strings.Join(names, ", "))
|
||||
}
|
||||
if len(names) == 0 || names[0] == "" {
|
||||
return "", fmt.Errorf("镜像 ID %q 存在但没有名称(未打标签),无法据此命名归档文件;请先 docker tag 再保存", image)
|
||||
}
|
||||
return names[0], nil
|
||||
}
|
||||
|
||||
// listImages asks docker for every local image. docker images prints a header
|
||||
// line, which is dropped explicitly.
|
||||
func listImages(ctx context.Context, runner diagnose.Runner) ([]imageRef, error) {
|
||||
result, err := runner.Run(ctx, "docker", "images", "--format", customFormat)
|
||||
if err != nil {
|
||||
return nil, runtimeError("images", result, err)
|
||||
}
|
||||
|
||||
var refs []imageRef
|
||||
for _, line := range strings.Split(string(result.Stdout), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "REPOSITORY") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, "\t")
|
||||
ref := imageRef{ID: strings.TrimSpace(fields[0])}
|
||||
if len(fields) >= 3 {
|
||||
repo, tag, digest := strings.TrimSpace(fields[1]), strings.TrimSpace(fields[2]), ""
|
||||
if len(fields) >= 4 {
|
||||
digest = strings.TrimSpace(fields[3])
|
||||
}
|
||||
ref.Name = formatImageName(repo, tag, digest)
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// formatImageName renders repository[:tag][@digest]. "<none>" is how both
|
||||
// runtimes group untagged images.
|
||||
func formatImageName(repo, tag, digest string) string {
|
||||
if repo == "<none>" {
|
||||
return ""
|
||||
}
|
||||
name := repo
|
||||
if tag != "" && tag != "<none>" {
|
||||
name += ":" + tag
|
||||
}
|
||||
if digest != "" && digest != "<none>" {
|
||||
name += "@" + digest
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// archiveName turns an image reference into a file name: every character that
|
||||
// cannot appear in a portable file name, including the ":" between repository
|
||||
// and tag, becomes "_". "alpine:3.20" -> "alpine_3.20.tar" and
|
||||
@@ -280,45 +166,3 @@ func archiveName(image string) string {
|
||||
}
|
||||
return b.String() + ".tar"
|
||||
}
|
||||
|
||||
// looksLikeImageID reports whether s is an ID (optionally sha256:-prefixed)
|
||||
// instead of a name.
|
||||
func looksLikeImageID(s string) bool {
|
||||
s = normalizeImageID(s)
|
||||
if len(s) < 4 || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeImageID(s string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(s, "sha256:"), "sha256"))
|
||||
}
|
||||
|
||||
// runRuntimeCommand runs one docker command, streaming its output.
|
||||
func runRuntimeCommand(ctx context.Context, args []string, req BuiltinRequest) error {
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Stdout = req.Stdout
|
||||
cmd.Stderr = req.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return &ExitError{Code: exitErr.ExitCode(), Err: err}
|
||||
}
|
||||
return fmt.Errorf("执行 docker 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runtimeError(subcommand string, result diagnose.CommandResult, err error) error {
|
||||
detail := strings.TrimSpace(string(result.Stderr))
|
||||
if detail == "" {
|
||||
detail = err.Error()
|
||||
}
|
||||
return fmt.Errorf("docker %s 失败: %s", subcommand, detail)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,15 @@ func TestLooksLikeImageID(t *testing.T) {
|
||||
// `images --format` payload; every invocation is appended to the returned log
|
||||
// file as one line.
|
||||
func fakeRuntime(t *testing.T, images string) (logPath string) {
|
||||
t.Helper()
|
||||
return fakeRuntimeScript(t, images, "", "")
|
||||
}
|
||||
|
||||
// fakeRuntimeScript installs a fake `docker` on PATH. images and ps are the
|
||||
// payloads of `docker images --format ...` and `docker ps -a --format ...`;
|
||||
// every invocation is appended to the returned log file as one line. Empty
|
||||
// payloads print nothing.
|
||||
func fakeRuntimeScript(t *testing.T, images, ps, extraCase string) (logPath string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
logPath = filepath.Join(dir, "calls.log")
|
||||
@@ -59,6 +68,9 @@ func fakeRuntime(t *testing.T, images string) (logPath string) {
|
||||
"case \"$1\" in\n" +
|
||||
" images) cat <<'EOF'\n" + images + "\nEOF\n" +
|
||||
" ;;\n" +
|
||||
" ps) cat <<'EOF'\n" + ps + "\nEOF\n" +
|
||||
" ;;\n" +
|
||||
extraCase +
|
||||
"esac\n"
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// runDockerStats implements `kopsc docker stats [容器...]`.
|
||||
//
|
||||
// Plain `docker stats` prints an endless live table, which is rarely what
|
||||
// anyone wants from a one-shot helper, so kopsc always asks for a single
|
||||
// snapshot (`--no-stream`). With no container named, every running container
|
||||
// is reported; with names, those are resolved to the running containers and
|
||||
// only they are shown. Options such as --format are forwarded.
|
||||
func runDockerStats(ctx context.Context, req BuiltinRequest) error {
|
||||
opts, targets, passthrough, err := parseDockerStats(req.Args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(targets) > 0 {
|
||||
refs, err := listContainers(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resolved := make([]string, 0, len(targets))
|
||||
for _, target := range targets {
|
||||
ref, err := matchContainerRef(refs, target)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ref.Running() {
|
||||
return &NotRunningError{Name: ref.Names}
|
||||
}
|
||||
resolved = append(resolved, ref.Names)
|
||||
}
|
||||
targets = resolved
|
||||
}
|
||||
|
||||
// docker stats takes the containers after its options; keeping the
|
||||
// forwarded options last matches how a caller would type them.
|
||||
args := append([]string{"stats"}, opts...)
|
||||
args = append(args, targets...)
|
||||
args = append(args, passthrough...)
|
||||
return dockerRun(ctx, args, req)
|
||||
}
|
||||
|
||||
// NotRunningError reports a container docker stats cannot sample.
|
||||
type NotRunningError struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
func (e *NotRunningError) Error() string {
|
||||
return "容器 " + e.Name + " 未在运行,docker stats 只能统计运行中的容器;先启动请用 kopsc docker start " + e.Name
|
||||
}
|
||||
|
||||
// parseDockerStats separates options from container names. --no-stream is
|
||||
// added by kopsc, so it is kept as an option and never duplicated.
|
||||
func parseDockerStats(args []string) (opts, targets, passthrough []string, err error) {
|
||||
opts = []string{"--no-stream"}
|
||||
afterDashDash := false
|
||||
for i := 0; i < len(args); i++ {
|
||||
arg := args[i]
|
||||
switch {
|
||||
case afterDashDash:
|
||||
targets = append(targets, arg)
|
||||
case arg == "--":
|
||||
afterDashDash = true
|
||||
case !strings.HasPrefix(arg, "-") || arg == "-":
|
||||
targets = append(targets, arg)
|
||||
case arg == "--no-stream":
|
||||
// Already requested by kopsc.
|
||||
default:
|
||||
passthrough = append(passthrough, arg)
|
||||
if !strings.Contains(arg, "=") && statsValueOption[strings.TrimLeft(arg, "-")] {
|
||||
if i+1 >= len(args) {
|
||||
return nil, nil, nil, &MissingOptionValueError{Option: arg}
|
||||
}
|
||||
i++
|
||||
passthrough = append(passthrough, args[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
return opts, targets, passthrough, nil
|
||||
}
|
||||
|
||||
// statsValueOption lists docker stats options that take a separate value.
|
||||
var statsValueOption = map[string]bool{
|
||||
"format": true,
|
||||
}
|
||||
|
||||
// MissingOptionValueError reports an option given without its value.
|
||||
type MissingOptionValueError struct {
|
||||
Option string
|
||||
}
|
||||
|
||||
func (e *MissingOptionValueError) Error() string {
|
||||
return e.Option + " 缺少参数"
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/diagnose"
|
||||
)
|
||||
|
||||
// This file holds the shared plumbing for the docker commands kopsc implements
|
||||
// itself: running docker, and resolving the image/container references a user
|
||||
// types (a name, an ID, or a unique ID prefix) to the concrete object.
|
||||
|
||||
// dockerRun streams one docker command's output to the caller's streams.
|
||||
func dockerRun(ctx context.Context, args []string, req BuiltinRequest) error {
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = req.Stdout
|
||||
cmd.Stderr = req.Stderr
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return &ExitError{Code: exitErr.ExitCode(), Err: err}
|
||||
}
|
||||
return fmt.Errorf("执行 docker 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dockerCapture runs one docker command and collects its output, so kopsc can
|
||||
// parse it before deciding what to do.
|
||||
func dockerCapture(ctx context.Context, args ...string) (diagnose.CommandResult, error) {
|
||||
return diagnose.ExecRunner{}.Run(ctx, "docker", args...)
|
||||
}
|
||||
|
||||
// dockerOutput runs one docker command and returns its stdout, turning a
|
||||
// failure into an error that carries docker's own message.
|
||||
func dockerOutput(ctx context.Context, args ...string) (string, error) {
|
||||
result, err := dockerCapture(ctx, args...)
|
||||
if err != nil {
|
||||
return "", dockerError(args[0], result, err)
|
||||
}
|
||||
return string(result.Stdout), nil
|
||||
}
|
||||
|
||||
// dockerError renders a failed docker invocation with its stderr.
|
||||
func dockerError(subcommand string, result diagnose.CommandResult, err error) error {
|
||||
detail := strings.TrimSpace(string(result.Stderr))
|
||||
if detail == "" {
|
||||
detail = err.Error()
|
||||
}
|
||||
return fmt.Errorf("docker %s 失败: %s", subcommand, detail)
|
||||
}
|
||||
|
||||
// splitLines splits docker output into non-empty, trimmed lines.
|
||||
func splitLines(out string) []string {
|
||||
var lines []string
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// imageRef is one local image: its repository[:tag][@digest] and its ID.
|
||||
type imageRef struct {
|
||||
Name string // repository[:tag][@digest], e.g. "alpine:3.20"; empty when untagged
|
||||
ID string // image ID, e.g. "sha256:abc..."
|
||||
CreatedAt string // human-readable creation time, as docker lists it
|
||||
Size string // human-readable size, as docker lists it
|
||||
Repository string
|
||||
Tag string
|
||||
}
|
||||
|
||||
// imageFormat lets a single `images` call list IDs together with names
|
||||
// (docker images -q prints IDs only).
|
||||
const imageFormat = `{{.ID}}{{"\t"}}{{if .Repository}}{{.Repository}}{{"\t"}}{{.Tag}}{{"\t"}}{{.Digest}}{{"\t"}}{{.CreatedAt}}{{"\t"}}{{.Size}}{{end}}`
|
||||
|
||||
// listImages asks docker for every local image. docker images prints a header
|
||||
// line, which is dropped explicitly.
|
||||
func listImages(ctx context.Context) ([]imageRef, error) {
|
||||
out, err := dockerOutput(ctx, "images", "--format", imageFormat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var refs []imageRef
|
||||
for _, line := range splitLines(out) {
|
||||
if strings.HasPrefix(line, "REPOSITORY") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, "\t")
|
||||
ref := imageRef{ID: strings.TrimSpace(fields[0])}
|
||||
if len(fields) >= 3 {
|
||||
repo, tag := strings.TrimSpace(fields[1]), strings.TrimSpace(fields[2])
|
||||
digest := ""
|
||||
if len(fields) >= 4 {
|
||||
digest = strings.TrimSpace(fields[3])
|
||||
}
|
||||
if len(fields) >= 5 {
|
||||
ref.CreatedAt = strings.TrimSpace(fields[4])
|
||||
}
|
||||
if len(fields) >= 6 {
|
||||
ref.Size = strings.TrimSpace(fields[5])
|
||||
}
|
||||
ref.Repository = repo
|
||||
ref.Tag = tag
|
||||
ref.Name = formatImageName(repo, tag, digest)
|
||||
}
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// formatImageName renders repository[:tag][@digest]. "<none>" is how docker
|
||||
// groups untagged images.
|
||||
func formatImageName(repo, tag, digest string) string {
|
||||
if repo == "<none>" {
|
||||
return ""
|
||||
}
|
||||
name := repo
|
||||
if tag != "" && tag != "<none>" {
|
||||
name += ":" + tag
|
||||
}
|
||||
if digest != "" && digest != "<none>" {
|
||||
name += "@" + digest
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// resolveImageRef maps a user-supplied image name or ID to the local image.
|
||||
//
|
||||
// A name is used as given once it is confirmed locally. An image ID — with or
|
||||
// without the sha256: prefix, or a unique prefix — resolves to the image that
|
||||
// carries it. Names always win over IDs, so a short name such as "alpine" is
|
||||
// never mistaken for an ID prefix. The returned ref always has a non-empty
|
||||
// Name: every kopsc command that takes an image needs one to act on.
|
||||
func resolveImageRef(ctx context.Context, image string) (imageRef, error) {
|
||||
refs, err := listImages(ctx)
|
||||
if err != nil {
|
||||
return imageRef{}, err
|
||||
}
|
||||
return matchImageRef(refs, image)
|
||||
}
|
||||
|
||||
// matchImageRef is resolveImageRef over an already-fetched image list.
|
||||
func matchImageRef(refs []imageRef, image string) (imageRef, error) {
|
||||
ref, err := lookupImageRef(refs, image)
|
||||
if err != nil {
|
||||
return imageRef{}, err
|
||||
}
|
||||
if ref.Name == "" {
|
||||
return imageRef{}, fmt.Errorf("镜像 %q 没有名称(未打标签),请先 docker tag %s <名称>:<标签>", image, ref.ID)
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// lookupImageRef is matchImageRef without the naming requirement: it may
|
||||
// return a ref with an empty Name for an untagged image.
|
||||
func lookupImageRef(refs []imageRef, image string) (imageRef, error) {
|
||||
for _, ref := range refs {
|
||||
if ref.Name == image {
|
||||
return ref, nil
|
||||
}
|
||||
}
|
||||
|
||||
if !looksLikeImageID(image) {
|
||||
return imageRef{}, fmt.Errorf("本地没有镜像 %q,可用 kopsc docker images 查看", image)
|
||||
}
|
||||
|
||||
// The same ID appears on one row per repository:tag, so group by ID.
|
||||
wanted := normalizeImageID(image)
|
||||
ids := map[string][]imageRef{}
|
||||
var order []string
|
||||
for _, ref := range refs {
|
||||
id := normalizeImageID(ref.ID)
|
||||
if !strings.HasPrefix(id, wanted) {
|
||||
continue
|
||||
}
|
||||
if _, seen := ids[id]; !seen {
|
||||
order = append(order, id)
|
||||
}
|
||||
ids[id] = append(ids[id], ref)
|
||||
}
|
||||
|
||||
switch len(order) {
|
||||
case 0:
|
||||
return imageRef{}, fmt.Errorf("本地没有镜像 ID %q,可用 kopsc docker images 查看", image)
|
||||
case 1:
|
||||
default:
|
||||
return imageRef{}, fmt.Errorf("镜像 ID %q 匹配多个本地镜像(%s),请改用镜像名称",
|
||||
image, strings.Join(order, ", "))
|
||||
}
|
||||
|
||||
rows := ids[order[0]]
|
||||
var names []string
|
||||
for _, row := range rows {
|
||||
if row.Name != "" {
|
||||
names = append(names, row.Name)
|
||||
}
|
||||
}
|
||||
resolved := rows[0]
|
||||
if len(names) > 0 {
|
||||
resolved.Name = names[0]
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
|
||||
// containerRef is one container as docker lists it.
|
||||
type containerRef struct {
|
||||
ID string // container ID, e.g. "3f2a..."
|
||||
Names string // comma-separated names without docker's leading "/"
|
||||
Image string
|
||||
Status string // e.g. "Up 2 hours", "Exited (0) 3 days ago"
|
||||
}
|
||||
|
||||
// Running reports whether the container is up, judged from its status line.
|
||||
func (c containerRef) Running() bool { return strings.HasPrefix(c.Status, "Up") }
|
||||
|
||||
// containerFormat lists every container (including stopped ones) with the
|
||||
// fields kopsc needs to identify it.
|
||||
const containerFormat = `{{.ID}}{{"\t"}}{{.Names}}{{"\t"}}{{.Image}}{{"\t"}}{{.Status}}`
|
||||
|
||||
// listContainers asks docker for every container, running or not.
|
||||
func listContainers(ctx context.Context) ([]containerRef, error) {
|
||||
out, err := dockerOutput(ctx, "ps", "-a", "--format", containerFormat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var refs []containerRef
|
||||
for _, line := range splitLines(out) {
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, containerRef{
|
||||
ID: strings.TrimSpace(fields[0]),
|
||||
Names: strings.TrimPrefix(strings.TrimSpace(fields[1]), "/"),
|
||||
Image: strings.TrimSpace(fields[2]),
|
||||
Status: strings.TrimSpace(fields[3]),
|
||||
})
|
||||
}
|
||||
return refs, nil
|
||||
}
|
||||
|
||||
// resolveContainerRef maps a user-supplied container name or ID to the local
|
||||
// container, following docker's own precedence: an exact name, then an exact
|
||||
// ID, then a unique ID prefix.
|
||||
func resolveContainerRef(ctx context.Context, target string) (containerRef, error) {
|
||||
refs, err := listContainers(ctx)
|
||||
if err != nil {
|
||||
return containerRef{}, err
|
||||
}
|
||||
return matchContainerRef(refs, target)
|
||||
}
|
||||
|
||||
// matchContainerRef is resolveContainerRef over an already-fetched list.
|
||||
func matchContainerRef(refs []containerRef, target string) (containerRef, error) {
|
||||
for _, ref := range refs {
|
||||
if hasName(ref, target) {
|
||||
return ref, nil
|
||||
}
|
||||
}
|
||||
|
||||
wanted := normalizeContainerID(target)
|
||||
if wanted != "" {
|
||||
for _, ref := range refs {
|
||||
if normalizeContainerID(ref.ID) == wanted {
|
||||
return ref, nil
|
||||
}
|
||||
}
|
||||
|
||||
var byPrefix []containerRef
|
||||
for _, ref := range refs {
|
||||
if strings.HasPrefix(normalizeContainerID(ref.ID), wanted) {
|
||||
byPrefix = append(byPrefix, ref)
|
||||
}
|
||||
}
|
||||
switch len(byPrefix) {
|
||||
case 1:
|
||||
return byPrefix[0], nil
|
||||
case 0:
|
||||
// Fall through to the "not found" error below.
|
||||
default:
|
||||
return containerRef{}, fmt.Errorf("容器 ID 前缀 %q 匹配多个容器(%s),请指定更长的 ID 或容器名称",
|
||||
target, strings.Join(containerIDs(byPrefix), ", "))
|
||||
}
|
||||
}
|
||||
|
||||
return containerRef{}, fmt.Errorf("本地没有容器 %q,可用 kopsc docker psall 查看", target)
|
||||
}
|
||||
|
||||
// hasName reports whether ref carries exactly the given name (docker may list
|
||||
// several aliases, comma-separated).
|
||||
func hasName(ref containerRef, name string) bool {
|
||||
for _, candidate := range strings.Split(ref.Names, ",") {
|
||||
if strings.TrimSpace(candidate) == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containerIDs(refs []containerRef) []string {
|
||||
ids := make([]string, len(refs))
|
||||
for i, ref := range refs {
|
||||
ids[i] = ref.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// looksLikeImageID reports whether s is an image ID (optionally
|
||||
// sha256:-prefixed, lowercase hex) instead of a name.
|
||||
func looksLikeImageID(s string) bool {
|
||||
s = normalizeImageID(s)
|
||||
if len(s) < 4 || len(s) > 64 {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if !(r >= '0' && r <= '9' || r >= 'a' && r <= 'f') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeImageID(s string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(strings.TrimPrefix(s, "sha256:"), "sha256"))
|
||||
}
|
||||
|
||||
// normalizeContainerID strips the separators from a container ID so prefixes
|
||||
// from `docker ps` and from the user compare equal.
|
||||
func normalizeContainerID(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// captureDocker is a small helper for builtins that need docker's output in a
|
||||
// buffer while still reporting failures uniformly.
|
||||
func captureDocker(ctx context.Context, args ...string) ([]byte, error) {
|
||||
result, err := dockerCapture(ctx, args...)
|
||||
if err != nil {
|
||||
return nil, dockerError(args[0], result, err)
|
||||
}
|
||||
return bytes.TrimSpace(result.Stdout), nil
|
||||
}
|
||||
|
||||
// startDockerPipe starts a docker command whose stdout is read line by line.
|
||||
// Stderr goes straight to the caller so errors such as "No such container"
|
||||
// are still shown.
|
||||
func startDockerPipe(ctx context.Context, args []string, stderr io.Writer) (*exec.Cmd, io.ReadCloser, error) {
|
||||
cmd := exec.CommandContext(ctx, "docker", args...)
|
||||
out, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("创建 docker 输出管道失败: %w", err)
|
||||
}
|
||||
cmd.Stderr = stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
return nil, nil, fmt.Errorf("启动 docker 失败: %w", err)
|
||||
}
|
||||
return cmd, out, nil
|
||||
}
|
||||
|
||||
// waitDockerPipe waits for a command started by startDockerPipe and forwards
|
||||
// its exit code, preferring a scan error over the exit status when the read
|
||||
// side failed first.
|
||||
func waitDockerPipe(cmd *exec.Cmd, scanErr error) error {
|
||||
err := cmd.Wait()
|
||||
if scanErr != nil {
|
||||
return fmt.Errorf("读取 docker 输出失败: %w", scanErr)
|
||||
}
|
||||
if err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if errors.As(err, &exitErr) {
|
||||
return &ExitError{Code: exitErr.ExitCode(), Err: err}
|
||||
}
|
||||
return fmt.Errorf("执行 docker 失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
//go:build !windows
|
||||
|
||||
package executor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
|
||||
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/resolve"
|
||||
)
|
||||
|
||||
func TestMatchContainerRefPrecedence(t *testing.T) {
|
||||
refs := []containerRef{
|
||||
{ID: "3f2a1b0c9d8e", Names: "web", Image: "nginx:1.25", Status: "Up 2 hours"},
|
||||
{ID: "3f2a99aa0011", Names: "worker", Image: "busybox", Status: "Exited (0) 1 day ago"},
|
||||
{ID: "aabbccdd1122", Names: "db,primary", Image: "postgres:16", Status: "Exited (0) 3 days ago"},
|
||||
{ID: "deadbeef0001", Names: "deadbeef", Image: "alpine", Status: "Exited (1) 1 hour ago"},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
target string
|
||||
wantID string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "exact name", target: "web", wantID: "3f2a1b0c9d8e"},
|
||||
{name: "second alias", target: "primary", wantID: "aabbccdd1122"},
|
||||
{name: "exact id", target: "aabbccdd1122", wantID: "aabbccdd1122"},
|
||||
{name: "unique id prefix", target: "3f2a1b", wantID: "3f2a1b0c9d8e"},
|
||||
{name: "unique id prefix two", target: "3f2a99", wantID: "3f2a99aa0011"},
|
||||
{name: "name wins over id prefix", target: "deadbeef", wantID: "deadbeef0001"},
|
||||
{name: "unknown", target: "nope", wantErr: "本地没有容器"},
|
||||
{name: "ambiguous prefix", target: "3f2a", wantErr: "匹配多个容器"},
|
||||
{name: "empty target", target: "", wantErr: "本地没有容器"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ref, err := matchContainerRef(refs, tc.target)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err = %v, want %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if ref.ID != tc.wantID {
|
||||
t.Fatalf("resolved %q to %q, want %q", tc.target, ref.ID, tc.wantID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContainerRefRunning(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"Up 2 hours": true,
|
||||
"Up 3 seconds (healthy)": true,
|
||||
"Exited (0) 3 days ago": false,
|
||||
"Created": false,
|
||||
"Restarting (1) 5 seconds ago": false,
|
||||
"": false,
|
||||
}
|
||||
for status, want := range cases {
|
||||
if got := (containerRef{Status: status}).Running(); got != want {
|
||||
t.Errorf("Status %q: Running() = %v, want %v", status, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListContainersParsesDockerPs(t *testing.T) {
|
||||
// The fake script answers `ps -a --format ...` with tab-separated rows,
|
||||
// the same shape docker prints with that format.
|
||||
logPath := fakeRuntimeScript(t, "",
|
||||
"3f2a1b0c9d8e\t/web\tnginx:1.25\tUp 2 hours\n"+
|
||||
"aabbccdd1122\tdb\tpostgres:16\tExited (0) 3 days ago", "")
|
||||
|
||||
refs, err := listContainers(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("listContainers: %v", err)
|
||||
}
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("got %d containers, want 2: %+v", len(refs), refs)
|
||||
}
|
||||
if refs[0].Names != "web" || refs[1].Names != "db" {
|
||||
t.Errorf("names = %q, %q; want web, db (leading / must be stripped)", refs[0].Names, refs[1].Names)
|
||||
}
|
||||
if !refs[0].Running() || refs[1].Running() {
|
||||
t.Errorf("unexpected running state: %+v", refs)
|
||||
}
|
||||
if calls := calls(t, logPath); len(calls) == 0 || !strings.HasPrefix(calls[0], "ps -a --format") {
|
||||
t.Errorf("docker calls = %q, want a `ps -a --format` invocation", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRunStreamsOutput(t *testing.T) {
|
||||
fakeRuntimeScript(t, "", "", " version) echo ' Server: 27.0'\n ;;\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
err := dockerRun(context.Background(), []string{"version"}, BuiltinRequest{Stdout: &out, Stderr: &out})
|
||||
if err != nil {
|
||||
t.Fatalf("dockerRun: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "Server: 27.0") {
|
||||
t.Errorf("stdout not streamed: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRunPropagatesExitCode(t *testing.T) {
|
||||
fakeRuntimeScript(t, "", "", " version) echo boom >&2; exit 4\n ;;\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
err := dockerRun(context.Background(), []string{"version"}, BuiltinRequest{Stdout: &out, Stderr: &out})
|
||||
if err == nil {
|
||||
t.Fatal("expected an error")
|
||||
}
|
||||
if code := ExitCode(err); code != 4 {
|
||||
t.Fatalf("exit code = %d, want 4", code)
|
||||
}
|
||||
if !strings.Contains(out.String(), "boom") {
|
||||
t.Errorf("stderr not forwarded: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerCaptureReturnsOutput(t *testing.T) {
|
||||
fakeRuntimeScript(t, "", "", " version) echo captured\n ;;\n")
|
||||
|
||||
got, err := dockerOutput(context.Background(), "version")
|
||||
if err != nil {
|
||||
t.Fatalf("dockerOutput: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(got) != "captured" {
|
||||
t.Fatalf("output = %q, want captured", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerOutputReportsDockerError(t *testing.T) {
|
||||
fakeRuntimeScript(t, "", "", " version) echo 'daemon not running' >&2; exit 1\n ;;\n")
|
||||
|
||||
_, err := dockerOutput(context.Background(), "version")
|
||||
if err == nil || !strings.Contains(err.Error(), "daemon not running") {
|
||||
t.Fatalf("err = %v, want docker's stderr in the message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func rmFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
return fakeRuntimeScript(t, imageList(),
|
||||
"3f2a1b0c9d8e\t/web\tnginx:1.25\tUp 2 hours\n"+
|
||||
"aabbccdd1122\tdb\tpostgres:16\tExited (0) 3 days ago", "")
|
||||
}
|
||||
|
||||
func TestDockerRMResolvesNameAndID(t *testing.T) {
|
||||
logPath := rmFixture(t)
|
||||
|
||||
// A container name and an ID prefix both reach docker as names. "web" is
|
||||
// running in the fixture, so only the stopped container is removable.
|
||||
if err := runDockerRM(context.Background(), BuiltinRequest{
|
||||
Args: []string{"db", "aabb"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("rm: %v", err)
|
||||
}
|
||||
|
||||
calls := calls(t, logPath)
|
||||
joined := strings.Join(calls, " | ")
|
||||
if !strings.Contains(joined, "rm db db") {
|
||||
t.Fatalf("docker calls = %q, want `rm db db`", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRMUnknownTargetDoesNotCallDocker(t *testing.T) {
|
||||
logPath := rmFixture(t)
|
||||
|
||||
err := runDockerRM(context.Background(), BuiltinRequest{Args: []string{"nosuch"}, Stdout: &bytes.Buffer{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "本地没有容器") {
|
||||
t.Fatalf("err = %v, want unknown-container error", err)
|
||||
}
|
||||
for _, call := range calls(t, logPath) {
|
||||
if strings.HasPrefix(call, "rm ") || call == "rm" {
|
||||
t.Fatalf("docker rm must not run for an unknown target, calls = %q", calls(t, logPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRMRequiresTarget(t *testing.T) {
|
||||
rmFixture(t)
|
||||
if err := runDockerRM(context.Background(), BuiltinRequest{}); err == nil ||
|
||||
!strings.Contains(err.Error(), "缺少容器") {
|
||||
t.Fatalf("err = %v, want missing-container error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRMIResolvesNameAndID(t *testing.T) {
|
||||
logPath := rmFixture(t)
|
||||
|
||||
if err := runDockerRMI(context.Background(), BuiltinRequest{
|
||||
Args: []string{"alpine:3.20"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("rmi by name: %v", err)
|
||||
}
|
||||
if err := runDockerRMI(context.Background(), BuiltinRequest{
|
||||
Args: []string{"sha256:5ca7b1d5e2fc"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("rmi by sha256 ID: %v", err)
|
||||
}
|
||||
if err := runDockerRMI(context.Background(), BuiltinRequest{
|
||||
Args: []string{"1b9a2c"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("rmi by ID prefix: %v", err)
|
||||
}
|
||||
|
||||
joined := strings.Join(calls(t, logPath), " | ")
|
||||
for _, want := range []string{
|
||||
"rmi alpine:3.20",
|
||||
"rmi nginx:1.25.3", // 5ca7… resolves to its name
|
||||
"rmi registry.example.com:5000/team/app:1.0", // 1b9a… likewise
|
||||
} {
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("docker calls missing %q: %s", want, joined)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRMIUntaggedUsesID(t *testing.T) {
|
||||
logPath := rmFixture(t)
|
||||
|
||||
// A dangling image has no name, so it is removed by ID.
|
||||
if err := runDockerRMI(context.Background(), BuiltinRequest{
|
||||
Args: []string{"7d3e5f7a9b1c"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("rmi: %v", err)
|
||||
}
|
||||
if joined := strings.Join(calls(t, logPath), " | "); !strings.Contains(joined, "rmi 7d3e5f7a9b1c") {
|
||||
t.Fatalf("docker calls = %s, want the ID", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRMIUnknownImageDoesNotCallDocker(t *testing.T) {
|
||||
logPath := rmFixture(t)
|
||||
|
||||
err := runDockerRMI(context.Background(), BuiltinRequest{Args: []string{"busybox:1"}, Stdout: &bytes.Buffer{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "本地没有镜像") {
|
||||
t.Fatalf("err = %v, want unknown-image error", err)
|
||||
}
|
||||
for _, call := range calls(t, logPath) {
|
||||
if strings.HasPrefix(call, "rmi") {
|
||||
t.Fatalf("docker rmi must not run for an unknown image, calls = %q", calls(t, logPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectForce(t *testing.T) {
|
||||
msg := "no -f"
|
||||
for _, args := range [][]string{{"-f"}, {"--force"}, {"--force=true"}, {"-fv"}, {"x", "-f"}} {
|
||||
if err := rejectForce(args, msg); err == nil {
|
||||
t.Errorf("args %v: expected -f to be rejected", args)
|
||||
}
|
||||
}
|
||||
for _, args := range [][]string{{"web"}, {"--force=false"}, {"--", "-f"}} {
|
||||
if err := rejectForce(args, msg); err != nil {
|
||||
t.Errorf("args %v: unexpected error %v", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerStopAndRestartResolveTargets(t *testing.T) {
|
||||
logPath := rmFixture(t)
|
||||
|
||||
if err := runDockerStop(context.Background(), BuiltinRequest{
|
||||
Args: []string{"3f2a1b", "-t", "5"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if err := runDockerRestart(context.Background(), BuiltinRequest{
|
||||
Args: []string{"db"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("restart: %v", err)
|
||||
}
|
||||
|
||||
joined := strings.Join(calls(t, logPath), " | ")
|
||||
// The ID prefix resolves to the container name; -t keeps its value.
|
||||
if !strings.Contains(joined, "stop web -t 5") {
|
||||
t.Errorf("docker calls = %s, want `stop web -t 5`", joined)
|
||||
}
|
||||
if !strings.Contains(joined, "restart db") {
|
||||
t.Errorf("docker calls = %s, want `restart db`", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerStopUnknownContainerPointsAtPsall(t *testing.T) {
|
||||
rmFixture(t)
|
||||
|
||||
err := runDockerStop(context.Background(), BuiltinRequest{Args: []string{"ghost"}, Stdout: &bytes.Buffer{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "kopsc docker psall") {
|
||||
t.Fatalf("err = %v, want a hint to run `kopsc docker psall`", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecDefaultsToInteractive(t *testing.T) {
|
||||
logPath := rmFixture(t)
|
||||
|
||||
// The command's own flags (-p, -i) belong to the command, not to docker.
|
||||
if err := runDockerExec(context.Background(), BuiltinRequest{
|
||||
Args: []string{"web", "sh", "-c", "echo hi"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("exec: %v", err)
|
||||
}
|
||||
joined := strings.Join(calls(t, logPath), " | ")
|
||||
if !strings.Contains(joined, "exec -it web -- sh -c echo hi") {
|
||||
t.Fatalf("docker calls = %s, want `exec -it web -- sh -c echo hi`", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecKeepsUserOptions(t *testing.T) {
|
||||
logPath := rmFixture(t)
|
||||
|
||||
if err := runDockerExec(context.Background(), BuiltinRequest{
|
||||
Args: []string{"-u", "0", "-w", "/app", "web", "env"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("exec: %v", err)
|
||||
}
|
||||
joined := strings.Join(calls(t, logPath), " | ")
|
||||
if !strings.Contains(joined, "exec -it -u 0 -w /app web -- env") {
|
||||
t.Fatalf("docker calls = %s, want the options kept before the container", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecRejectsExplicitInteractiveFlags(t *testing.T) {
|
||||
rmFixture(t)
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"-it", "web", "sh"},
|
||||
{"-i", "web", "sh"},
|
||||
{"-t", "web", "sh"},
|
||||
{"--interactive", "web", "sh"},
|
||||
{"-ti", "web", "sh"},
|
||||
} {
|
||||
err := runDockerExec(context.Background(), BuiltinRequest{Args: args, Stdout: &bytes.Buffer{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "已默认以交互方式") {
|
||||
t.Errorf("args %v: err = %v, want the interactive-flag rejection", args, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerExecRequiresContainer(t *testing.T) {
|
||||
rmFixture(t)
|
||||
if err := runDockerExec(context.Background(), BuiltinRequest{}); err == nil ||
|
||||
!strings.Contains(err.Error(), "缺少容器") {
|
||||
t.Fatalf("err = %v, want missing-container error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecArgv(t *testing.T) {
|
||||
got := strings.Join(execArgv([]string{"-u", "0"}, "web", "sh", "-c", "id"), " ")
|
||||
if want := "exec -it -u 0 web -- sh -c id"; got != want {
|
||||
t.Fatalf("execArgv = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// logsScript makes a fake docker print three log lines for `logs`.
|
||||
func logsScript(t *testing.T) string {
|
||||
t.Helper()
|
||||
return fakeRuntimeScript(t, "", "", " logs) printf 'INFO start\\nERROR boom\\nINFO done\\n'\n ;;\n")
|
||||
}
|
||||
|
||||
func TestDockerLogsStreamsVerbatimWithoutKopscOptions(t *testing.T) {
|
||||
logPath := logsScript(t)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := runDockerLogs(context.Background(), BuiltinRequest{
|
||||
Args: []string{"web", "-f", "--tail", "10"}, Stdout: &out,
|
||||
}); err != nil {
|
||||
t.Fatalf("logs: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "ERROR boom") {
|
||||
t.Errorf("all lines should be shown, got %q", out.String())
|
||||
}
|
||||
joined := strings.Join(calls(t, logPath), " | ")
|
||||
// -f and --tail come after the container and go to docker untouched.
|
||||
if !strings.Contains(joined, "logs web -f --tail 10") {
|
||||
t.Errorf("docker calls = %s, want `logs web -f --tail 10`", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerLogsGrepFiltersLines(t *testing.T) {
|
||||
logsScript(t)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := runDockerLogs(context.Background(), BuiltinRequest{
|
||||
Args: []string{"--grep", "^ERROR", "web"}, Stdout: &out,
|
||||
}); err != nil {
|
||||
t.Fatalf("logs: %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), "INFO") {
|
||||
t.Errorf("INFO lines must be filtered out, got %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "ERROR boom") {
|
||||
t.Errorf("the matching line is missing: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerLogsSaveWritesFile(t *testing.T) {
|
||||
logsScript(t)
|
||||
path := filepath.Join(t.TempDir(), "sub", "web.log")
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := runDockerLogs(context.Background(), BuiltinRequest{
|
||||
Args: []string{"--save", path, "web"}, Stdout: &out,
|
||||
}); err != nil {
|
||||
t.Fatalf("logs: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "ERROR boom") {
|
||||
t.Errorf("logs should still be shown, got %q", out.String())
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("log file not written: %v", err)
|
||||
}
|
||||
if string(data) != "INFO start\nERROR boom\nINFO done\n" {
|
||||
t.Errorf("log file = %q", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerLogsPassesSinceOnce(t *testing.T) {
|
||||
logPath := logsScript(t)
|
||||
|
||||
if err := runDockerLogs(context.Background(), BuiltinRequest{
|
||||
Args: []string{"--since", "2h", "--grep", "INFO", "web"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("logs: %v", err)
|
||||
}
|
||||
call := strings.Join(calls(t, logPath), " | ")
|
||||
if strings.Count(call, "--since") != 1 || !strings.Contains(call, "logs web --since 2h") {
|
||||
t.Errorf("docker calls = %s, want exactly one --since 2h", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerLogsRejectsBadArguments(t *testing.T) {
|
||||
logsScript(t)
|
||||
|
||||
tests := []struct {
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{nil, "缺少容器"},
|
||||
{[]string{"--grep"}, "--grep 缺少参数"},
|
||||
{[]string{"--grep", "[", "web"}, "正则表达式无效"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
err := runDockerLogs(context.Background(), BuiltinRequest{Args: tc.args, Stdout: &bytes.Buffer{}})
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDockerSize(t *testing.T) {
|
||||
cases := map[string]int64{
|
||||
"0B": 0,
|
||||
"512B": 512,
|
||||
"-": 0,
|
||||
"": 0,
|
||||
"1KB": 1000,
|
||||
"1.234GB": 1234000000,
|
||||
"1.5GB (25%)": 1500000000,
|
||||
"2.1GB (2.1GB)": 2100000000,
|
||||
"1KiB": 1024,
|
||||
"3 MiB": 3 << 20,
|
||||
"1024": 1024,
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := parseDockerSize(in)
|
||||
if err != nil {
|
||||
t.Errorf("parseDockerSize(%q): %v", in, err)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("parseDockerSize(%q) = %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
if _, err := parseDockerSize("lots"); err == nil {
|
||||
t.Error("parseDockerSize should reject non-numeric sizes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerDFRanksBySize(t *testing.T) {
|
||||
logPath := fakeRuntimeScript(t, "", "", " system) printf 'TYPE\\tTOTAL\\tACTIVE\\tSIZE\\tRECLAIMABLE\\nContainers\\t12\\t3\\t512MB\\t256MB\\nImages\\t30\\t5\\t3.5GB\\t1.2GB\\nLocal Volumes\\t8\\t2\\t900MB\\t900MB\\nBuild Cache\\t40\\t10\\t1.1GB\\t1.1GB\\n'\n ;;\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := runDockerDF(context.Background(), BuiltinRequest{Stdout: &out, Stderr: &out}); err != nil {
|
||||
t.Fatalf("df: %v", err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
||||
// header + 4 rows + total + blank + hint
|
||||
if len(lines) < 6 {
|
||||
t.Fatalf("unexpected output:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(lines[0], "类型") || !strings.Contains(lines[0], "可回收") {
|
||||
t.Errorf("header = %q", lines[0])
|
||||
}
|
||||
// Largest first: Images (3.5GB) > Build Cache (1.1GB) > Volumes (900MB) > Containers.
|
||||
wantOrder := []string{"Images", "Build Cache", "Local Volumes", "Containers"}
|
||||
for i, want := range wantOrder {
|
||||
if !strings.Contains(lines[i+1], want) {
|
||||
t.Errorf("line %d = %q, want %q first", i+1, lines[i+1], want)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(lines[5], "总计") {
|
||||
t.Errorf("total line = %q", lines[5])
|
||||
}
|
||||
if !strings.Contains(out.String(), "可回收") {
|
||||
t.Errorf("missing reclaim hint:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(strings.Join(calls(t, logPath), " "), "system df") {
|
||||
t.Error("df should call `docker system df`")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerDFNoReclaimHint(t *testing.T) {
|
||||
fakeRuntimeScript(t, "", "", " system) printf 'TYPE\\tTOTAL\\tACTIVE\\tSIZE\\tRECLAIMABLE\\nImages\\t1\\t1\\t0B\\t0B\\n'\n ;;\n")
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := runDockerDF(context.Background(), BuiltinRequest{Stdout: &out, Stderr: &out}); err != nil {
|
||||
t.Fatalf("df: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "没有可回收的空间") {
|
||||
t.Errorf("output = %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerDFEmptyOutputIsAnError(t *testing.T) {
|
||||
fakeRuntimeScript(t, "", "", "")
|
||||
if err := runDockerDF(context.Background(), BuiltinRequest{Stdout: &bytes.Buffer{}}); err == nil ||
|
||||
!strings.Contains(err.Error(), "没有返回任何数据") {
|
||||
t.Fatalf("err = %v, want empty-output error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func statsFixture(t *testing.T) string {
|
||||
t.Helper()
|
||||
return fakeRuntimeScript(t, "",
|
||||
"3f2a1b0c9d8e\tweb\tnginx:1.25\tUp 2 hours\n"+
|
||||
"aabbccdd1122\tdb\tpostgres:16\tExited (0) 3 days ago", "")
|
||||
}
|
||||
|
||||
func TestDockerStatsWithoutArgsUsesNoStream(t *testing.T) {
|
||||
logPath := statsFixture(t)
|
||||
|
||||
if err := runDockerStats(context.Background(), BuiltinRequest{Stdout: &bytes.Buffer{}}); err != nil {
|
||||
t.Fatalf("stats: %v", err)
|
||||
}
|
||||
joined := strings.Join(calls(t, logPath), " | ")
|
||||
if !strings.Contains(joined, "stats --no-stream") {
|
||||
t.Fatalf("docker calls = %s, want `stats --no-stream`", joined)
|
||||
}
|
||||
// Without names docker reports every running container itself; kopsc must
|
||||
// not invent a container list.
|
||||
if strings.Contains(joined, " ps ") {
|
||||
t.Errorf("stats without args should not list containers first: %s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerStatsResolvesNamedContainer(t *testing.T) {
|
||||
logPath := statsFixture(t)
|
||||
|
||||
if err := runDockerStats(context.Background(), BuiltinRequest{
|
||||
Args: []string{"3f2a1b", "--format", "{{.Name}}"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("stats: %v", err)
|
||||
}
|
||||
joined := strings.Join(calls(t, logPath), " | ")
|
||||
if !strings.Contains(joined, "stats --no-stream web --format {{.Name}}") {
|
||||
t.Fatalf("docker calls = %s, want the resolved name and forwarded --format", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerStatsRejectsStoppedContainer(t *testing.T) {
|
||||
statsFixture(t)
|
||||
|
||||
err := runDockerStats(context.Background(), BuiltinRequest{Args: []string{"db"}, Stdout: &bytes.Buffer{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "未在运行") {
|
||||
t.Fatalf("err = %v, want not-running error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerStatsDoesNotDuplicateNoStream(t *testing.T) {
|
||||
logPath := statsFixture(t)
|
||||
|
||||
if err := runDockerStats(context.Background(), BuiltinRequest{
|
||||
Args: []string{"--no-stream"}, Stdout: &bytes.Buffer{},
|
||||
}); err != nil {
|
||||
t.Fatalf("stats: %v", err)
|
||||
}
|
||||
call := strings.Join(calls(t, logPath), " | ")
|
||||
if strings.Count(call, "--no-stream") != 1 {
|
||||
t.Errorf("--no-stream must appear once, calls = %s", call)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerPreviewRMListsContainers(t *testing.T) {
|
||||
rmFixture(t)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := runDockerPreviewRM(context.Background(), BuiltinRequest{
|
||||
Args: []string{"web", "aabb"}, Stdout: &out,
|
||||
}); err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
text := out.String()
|
||||
header := strings.Split(strings.TrimSpace(text), "\n")[0]
|
||||
for _, col := range []string{"ID", "名称", "镜像", "状态"} {
|
||||
if !strings.Contains(header, col) {
|
||||
t.Errorf("header %q missing column %q", header, col)
|
||||
}
|
||||
}
|
||||
// Each target is one row: ID, name, image, status.
|
||||
for _, want := range []string{"3f2a1b0c9d8e", "web", "nginx:1.25", "Up 2 hours", "db", "postgres:16", "Exited (0) 3 days ago"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("preview missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerPreviewRMRejectsUnknownBeforeYes(t *testing.T) {
|
||||
rmFixture(t)
|
||||
err := runDockerPreviewRM(context.Background(), BuiltinRequest{Args: []string{"ghost"}, Stdout: &bytes.Buffer{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "本地没有容器") {
|
||||
t.Fatalf("err = %v, want unknown-container error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerPreviewRMIUsesNameIDCreatedSize(t *testing.T) {
|
||||
rmFixture(t)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := runDockerPreviewRMI(context.Background(), BuiltinRequest{
|
||||
Args: []string{"alpine:3.20", "7d3e5f7a9b1c"}, Stdout: &out,
|
||||
}); err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
text := out.String()
|
||||
header := strings.Split(strings.TrimSpace(text), "\n")[0]
|
||||
for _, col := range []string{"镜像", "ID", "创建", "大小"} {
|
||||
if !strings.Contains(header, col) {
|
||||
t.Errorf("header %q missing column %q", header, col)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(text, "alpine:3.20") || !strings.Contains(text, "9c6f07244728") {
|
||||
t.Errorf("named image row missing:\n%s", text)
|
||||
}
|
||||
// The dangling image is shown as such rather than with a blank name.
|
||||
if !strings.Contains(text, "悬空镜像") {
|
||||
t.Errorf("dangling image not marked:\n%s", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortID(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"sha256:9c6f0724472892f3061e": "9c6f07244728",
|
||||
"3f2a1b0c9d8e": "3f2a1b0c9d8e",
|
||||
"abc": "abc",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := shortID(in); got != want {
|
||||
t.Errorf("shortID(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRMAndRMIRejectForce(t *testing.T) {
|
||||
for _, flag := range []string{"-f", "--force", "--force=true"} {
|
||||
logPath := rmFixture(t)
|
||||
err := runDockerRM(context.Background(), BuiltinRequest{Args: []string{flag, "web"}, Stdout: &bytes.Buffer{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "不再接受 -f") || !strings.Contains(err.Error(), "--yes") {
|
||||
t.Errorf("rm %s: err = %v, want -f rejection pointing at --yes", flag, err)
|
||||
}
|
||||
for _, call := range calls(t, logPath) {
|
||||
if call == "rm" || strings.HasPrefix(call, "rm ") {
|
||||
t.Errorf("rm %s must not call docker, calls = %q", flag, calls(t, logPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
logPath := rmFixture(t)
|
||||
err := runDockerRMI(context.Background(), BuiltinRequest{Args: []string{"--force", "alpine:3.20"}, Stdout: &bytes.Buffer{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "不再接受 -f") {
|
||||
t.Fatalf("rmi --force: err = %v, want -f rejection", err)
|
||||
}
|
||||
for _, call := range calls(t, logPath) {
|
||||
if strings.HasPrefix(call, "rmi") {
|
||||
t.Errorf("rmi must not call docker, calls = %q", calls(t, logPath))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerRMRejectsRunningContainer(t *testing.T) {
|
||||
logPath := rmFixture(t)
|
||||
|
||||
err := runDockerRM(context.Background(), BuiltinRequest{Args: []string{"web"}, Stdout: &bytes.Buffer{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "正在运行") || !strings.Contains(err.Error(), "kopsc docker stop web") {
|
||||
t.Fatalf("err = %v, want a hint to stop the container first", err)
|
||||
}
|
||||
for _, call := range calls(t, logPath) {
|
||||
if call == "rm" || strings.HasPrefix(call, "rm ") {
|
||||
t.Errorf("docker rm must not run for a running container, calls = %q", calls(t, logPath))
|
||||
}
|
||||
}
|
||||
|
||||
// A stopped container is removable.
|
||||
if err := runDockerRM(context.Background(), BuiltinRequest{Args: []string{"db"}, Stdout: &bytes.Buffer{}}); err != nil {
|
||||
t.Fatalf("removing a stopped container: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDockerPreviewRMMarksRunningContainer(t *testing.T) {
|
||||
rmFixture(t)
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := runDockerPreviewRM(context.Background(), BuiltinRequest{Args: []string{"web"}, Stdout: &out}); err != nil {
|
||||
t.Fatalf("preview: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "需先 kopsc docker stop web") {
|
||||
t.Errorf("preview should warn about the running container:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunNeverTouchesDocker(t *testing.T) {
|
||||
for _, name := range []string{"docker.rm", "docker.rmi", "docker.stop", "docker.exec", "docker.logs", "docker.logs --grep"} {
|
||||
logPath := rmFixture(t)
|
||||
builtinName, args := name, []string{"web"}
|
||||
if name == "docker.rmi" {
|
||||
args = []string{"alpine:3.20"}
|
||||
}
|
||||
if name == "docker.logs --grep" {
|
||||
builtinName, args = "docker.logs", []string{"--grep", "ERROR", "web"}
|
||||
}
|
||||
|
||||
fn, ok := LookupBuiltin(builtinName)
|
||||
if !ok {
|
||||
t.Fatalf("builtin %q not registered", builtinName)
|
||||
}
|
||||
// A dry-run is handled before any builtin runs; verify the executor's
|
||||
// contract directly.
|
||||
plan := Plan{Result: resolveResultWithVariant(registryVariantBuiltinFor(builtinName)), ExtraArgs: args}
|
||||
var out bytes.Buffer
|
||||
if err := plan.Run(context.Background(), Options{DryRun: true, Stdout: &out}); err != nil {
|
||||
t.Fatalf("%s dry-run: %v", builtinName, err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "[dry-run]") {
|
||||
t.Errorf("%s: dry-run output missing marker: %q", builtinName, out.String())
|
||||
}
|
||||
if got := calls(t, logPath); len(got) != 0 {
|
||||
t.Errorf("%s: dry-run must not call docker, calls = %q", builtinName, got)
|
||||
}
|
||||
_ = fn
|
||||
}
|
||||
}
|
||||
|
||||
// registryVariantBuiltinFor builds the resolved variant kopsc would pick for
|
||||
// the given builtin name, so direct Plan tests exercise the real path.
|
||||
func registryVariantBuiltinFor(name string) registry.Variant {
|
||||
return registry.Variant{Builtin: name, Runtime: "docker", Match: registry.Match{OS: "linux"}}
|
||||
}
|
||||
|
||||
func resolveResultWithVariant(v registry.Variant) resolve.Result {
|
||||
return resolve.Result{Command: ®istry.Command{ID: "docker.test"}, Variant: v}
|
||||
}
|
||||
@@ -57,6 +57,12 @@ func (p Plan) Display() string {
|
||||
return strings.Join(argv, " ")
|
||||
}
|
||||
|
||||
// PreviewBuiltin returns the builtin advertised by the resolved command for
|
||||
// listing what it would touch, or "" when the command has none.
|
||||
func (p Plan) PreviewBuiltin() string {
|
||||
return p.Result.Command.ConfirmBuiltin
|
||||
}
|
||||
|
||||
// Explain writes why this variant was chosen and what will run.
|
||||
func (p Plan) Explain(w io.Writer) {
|
||||
if w == nil {
|
||||
|
||||
@@ -30,15 +30,18 @@ func Builtin() *Registry {
|
||||
ID: "docker.stats",
|
||||
Group: "docker",
|
||||
Summary: "一次性查看容器 CPU/内存/IO 占用",
|
||||
Action: "只读:等价 docker stats --no-stream,输出一次后退出",
|
||||
Variants: runtimeVariants("stats", "--no-stream"),
|
||||
Action: "只读:等价 docker stats --no-stream,输出一次后退出;不给容器名时自动统计全部运行中的容器," +
|
||||
"给了名称或 ID 时只统计这些(需正在运行);--format 等选项可透传",
|
||||
Usage: "[容器...]",
|
||||
Variants: runtimeBuiltinVariants("stats"),
|
||||
},
|
||||
{
|
||||
ID: "docker.df",
|
||||
Group: "docker",
|
||||
Summary: "查看镜像/容器/卷/构建缓存的空间占用",
|
||||
Action: "只读:等价 docker system df",
|
||||
Variants: runtimeVariants("system", "df"),
|
||||
Summary: "磁盘占用排行(镜像/容器/卷/构建缓存)",
|
||||
Action: "只读:在 docker system df 之上按占用大小降序排列,输出「类型 / 项 / 可清理 / 大小 / 可回收」," +
|
||||
"并给出总计与一句可回收空间提示",
|
||||
Variants: runtimeBuiltinVariants("df"),
|
||||
},
|
||||
{
|
||||
ID: "docker.info",
|
||||
@@ -57,11 +60,12 @@ func Builtin() *Registry {
|
||||
{
|
||||
ID: "docker.logs",
|
||||
Group: "docker",
|
||||
Summary: "查看容器日志",
|
||||
Action: "只读:等价 docker logs <容器>;-f、--tail 等选项可透传",
|
||||
Usage: "<容器>",
|
||||
Summary: "查看容器日志(可过滤、可落盘)",
|
||||
Action: "等价 docker logs <容器>,并增加 kopsc 选项:--since 只看某时间段、--grep 只显示匹配行、--save 把显示的日志追加写入文件;" +
|
||||
"--since/--grep/--save 必须写在容器名之前,容器名之后的 -f、--tail 等原样交给 docker",
|
||||
Usage: "<容器> [选项...]",
|
||||
MinArgs: 1,
|
||||
Variants: runtimeVariants("logs"),
|
||||
Variants: runtimeBuiltinVariants("logs"),
|
||||
},
|
||||
{
|
||||
ID: "docker.inspect",
|
||||
@@ -100,47 +104,57 @@ func Builtin() *Registry {
|
||||
{
|
||||
ID: "docker.stop",
|
||||
Group: "docker",
|
||||
Summary: "停止运行中的容器",
|
||||
Action: "向容器发送 SIGTERM,超时后由运行时强制结束",
|
||||
Usage: "<容器>",
|
||||
Summary: "停止运行中的容器(按名称或 ID)",
|
||||
Action: "等价 docker stop:容器按名称、ID 或唯一 ID 前缀解析,一次可停多个;" +
|
||||
"先发送 SIGTERM,超时后由 docker 强制结束。-t/--time、-s/--signal 可透传",
|
||||
Usage: "<容器>...",
|
||||
MinArgs: 1,
|
||||
Variants: runtimeVariants("stop"),
|
||||
Variants: runtimeBuiltinVariants("stop"),
|
||||
},
|
||||
{
|
||||
ID: "docker.restart",
|
||||
Group: "docker",
|
||||
Summary: "重启容器",
|
||||
Action: "停止并重新启动一个或多个容器",
|
||||
Usage: "<容器>",
|
||||
Summary: "重启容器(按名称或 ID)",
|
||||
Action: "等价 docker restart:容器按名称、ID 或唯一 ID 前缀解析,一次可重启多个;" +
|
||||
"-t/--time、-s/--signal 可透传",
|
||||
Usage: "<容器>...",
|
||||
MinArgs: 1,
|
||||
Variants: runtimeVariants("restart"),
|
||||
Variants: runtimeBuiltinVariants("restart"),
|
||||
},
|
||||
{
|
||||
ID: "docker.rm",
|
||||
Group: "docker",
|
||||
Summary: "删除已停止的容器",
|
||||
Action: "删除指定容器;运行中的容器需要先停止(不会自动加 -f)",
|
||||
Usage: "<容器>",
|
||||
Summary: "删除容器(按名称或 ID)",
|
||||
Action: "等价 docker rm:容器按名称、ID 或唯一 ID 前缀解析,一次可删多个;" +
|
||||
"运行中的容器不会被强制删除,需先 kopsc docker stop。不再接受 -f,需要强删请用 --yes 确认",
|
||||
Usage: "<容器>...",
|
||||
MinArgs: 1,
|
||||
Variants: runtimeVariants("rm"),
|
||||
RequiresYes: true,
|
||||
ConfirmBuiltin: "docker.rm.preview",
|
||||
Variants: runtimeBuiltinVariants("rm"),
|
||||
},
|
||||
{
|
||||
ID: "docker.rmi",
|
||||
Group: "docker",
|
||||
Summary: "删除本地镜像",
|
||||
Action: "删除指定镜像;被容器引用的镜像会被运行时拒绝删除",
|
||||
Usage: "<镜像>",
|
||||
Summary: "删除本地镜像(按名称或 ID)",
|
||||
Action: "等价 docker rmi:镜像按名称、ID、sha256:ID 或唯一 ID 前缀解析,一次可删多个;" +
|
||||
"未打标签的悬空镜像按 ID 删除。不再接受 -f,需要强删请用 --yes 确认",
|
||||
Usage: "<镜像名|镜像ID>...",
|
||||
MinArgs: 1,
|
||||
Variants: runtimeVariants("rmi"),
|
||||
RequiresYes: true,
|
||||
ConfirmBuiltin: "docker.rmi.preview",
|
||||
Variants: runtimeBuiltinVariants("rmi"),
|
||||
},
|
||||
{
|
||||
ID: "docker.exec",
|
||||
Group: "docker",
|
||||
Summary: "在运行中的容器内执行命令",
|
||||
Action: "等价 docker exec <容器> <命令...>;交互场景可透传 -it",
|
||||
Summary: "在运行中的容器内执行命令(默认交互)",
|
||||
Action: "等价 docker exec -it <容器> -- <命令...>:默认就是交互模式,无需再写 -it(显式传 -i/-t/-it 会报错);" +
|
||||
"容器名之后的参数一律作为在容器内执行的命令,其中的 -i、-p 等不会被当成 docker 选项;" +
|
||||
"容器名之前的选项(如 -u、-w、-e)原样透传给 docker",
|
||||
Usage: "<容器> <命令...>",
|
||||
MinArgs: 1,
|
||||
Variants: runtimeVariants("exec"),
|
||||
Variants: runtimeBuiltinVariants("exec"),
|
||||
},
|
||||
{
|
||||
ID: "docker.pull",
|
||||
|
||||
@@ -49,6 +49,11 @@ type Command struct {
|
||||
// RequiresYes marks destructive commands: without an explicit --yes they
|
||||
// print what they would do and exit non-zero instead of running.
|
||||
RequiresYes bool
|
||||
// ConfirmBuiltin names a builtin that lists exactly what the command would
|
||||
// touch (the containers or images about to be deleted). It runs before the
|
||||
// confirmation prompt and for --dry-run; its output is shown to the
|
||||
// caller. Empty means "no preview available".
|
||||
ConfirmBuiltin string
|
||||
// Namespaced marks Kubernetes-aware commands where -n means --namespace
|
||||
// (kubectl style) instead of the global --dry-run shorthand.
|
||||
Namespaced bool
|
||||
|
||||
@@ -213,6 +213,9 @@ func TestDockerCommandsWellFormed(t *testing.T) {
|
||||
destructive := map[string]bool{
|
||||
"docker.clearall": true,
|
||||
"docker.prunevolumes": true,
|
||||
// rm/rmi now list their targets and require an explicit --yes.
|
||||
"docker.rm": true,
|
||||
"docker.rmi": true,
|
||||
}
|
||||
needsArgs := map[string]int{
|
||||
"docker.logs": 1,
|
||||
@@ -231,8 +234,16 @@ func TestDockerCommandsWellFormed(t *testing.T) {
|
||||
}
|
||||
// Built from kopsc's own implementation rather than a fixed argv.
|
||||
builtin := map[string]string{
|
||||
"docker.save": "docker.save",
|
||||
"docker.df": "docker.df",
|
||||
"docker.exec": "docker.exec",
|
||||
"docker.logs": "docker.logs",
|
||||
"docker.stats": "docker.stats",
|
||||
"docker.load": "docker.load",
|
||||
"docker.restart": "docker.restart",
|
||||
"docker.rm": "docker.rm",
|
||||
"docker.rmi": "docker.rmi",
|
||||
"docker.save": "docker.save",
|
||||
"docker.stop": "docker.stop",
|
||||
}
|
||||
|
||||
for i := range reg.Commands {
|
||||
|
||||
Reference in New Issue
Block a user