Files
kenaito-kopsc/internal/executor/dockertargets_test.go
T
odboy f5ae80c1a8 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 调用
2026-11-01 08:01:12 +08:00

774 lines
26 KiB
Go

//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: &registry.Command{ID: "docker.test"}, Variant: v}
}