feat: 新增 proc nohup 后台运行、docker save/load 内置实现,移除 Podman 支持

- proc.nohup:后台运行命令并将 stdout/stderr 追加写入日志,
  Unix 下新会话、Windows 下 DETACHED_PROCESS 脱离终端
- docker.save:等价 docker save <镜像> -o <镜像名>.tar,文件名按
  仓库名+标签生成(alpine:3.20 → alpine_3.20.tar),传镜像 ID 时
  先解析出对应名称;docker.load 改为归档文件作位置参数
- 移除 Podman 支持,所有容器命令只走 docker
This commit is contained in:
2026-11-01 07:47:57 +08:00
parent 6002a3afd1
commit bd6f20d7c5
19 changed files with 1551 additions and 120 deletions
+73 -28
View File
@@ -161,20 +161,26 @@ func Builtin() *Registry {
Variants: runtimeVariants("tag"),
},
{
ID: "docker.save",
Group: "docker",
Summary: "把镜像导出为归档文件",
Action: "等价 docker save;通常配合 -o 透传输出文件",
Usage: "<镜像>",
ID: "docker.save",
Group: "docker",
Summary: "把镜像导出为 <镜像名>.tar",
Action: "等价 docker save <镜像> -o <镜像名>.tar:归档文件按镜像的仓库名与标签命名," +
"标签的冒号改写为下划线(alpine:3.20 → alpine_3.20.tar,registry:5000/team/app:1.0 → registry_5000_team_app_1.0.tar);" +
"传入镜像 ID 时先解析出它对应的镜像名称,再据此命名;未打标签的镜像直接报错。" +
"--platform 等选项原样透传给底层运行时",
Usage: "<镜像名|镜像ID>",
MinArgs: 1,
Variants: runtimeVariants("save"),
Variants: runtimeBuiltinVariants("save"),
},
{
ID: "docker.load",
Group: "docker",
Summary: "从归档文件导入镜像",
Action: "等价 docker load;通常配合 -i 透传输入文件",
Variants: runtimeVariants("load"),
ID: "docker.load",
Group: "docker",
Summary: "从 <归档.tar> 导入镜像",
Action: "等价 docker load -i <归档.tar>:归档文件作为位置参数(对应原本的 docker load < xxx.tar)," +
"可一次给出多个;仍可用 -i/--input 指定,或传 - 表示从标准输入读取",
Usage: "<归档.tar>...",
MinArgs: 1,
Variants: runtimeBuiltinVariants("load"),
},
{
ID: "docker.cp",
@@ -275,7 +281,7 @@ func Builtin() *Registry {
ID: "diagnose",
Group: "diagnose",
Summary: "诊断容器或 Pod 当前状态的根因",
Action: "自动识别 Docker/Podman 容器或 Kubernetes Pod,采集状态、最近日志与事件,给出原因、依据与建议;只读,不修改任何对象。--kind 可强制来源,K8s 下 -n/--namespace 指定命名空间",
Action: "自动识别 Docker 容器或 Kubernetes Pod,采集状态、最近日志与事件,给出原因、依据与建议;只读,不修改任何对象。--kind 可强制来源,K8s 下 -n/--namespace 指定命名空间",
Usage: "<容器名或 Pod 名>",
MinArgs: 1,
Namespaced: true,
@@ -283,7 +289,7 @@ func Builtin() *Registry {
{
Match: Match{OS: "linux"},
Builtin: "diagnose",
Note: "Linux 内置诊断(docker/podman 与 kubectl 自动识别)",
Note: "Linux 内置诊断(docker 与 kubectl 自动识别)",
},
{
Match: Match{OS: "darwin"},
@@ -372,6 +378,35 @@ func Builtin() *Registry {
},
},
},
{
ID: "proc.nohup",
Group: "proc",
Summary: "在后台运行命令,输出写入日志(等价 nohup <命令> > output.log 2>&1 &)",
Action: "启动一个与当前终端脱离的命令并立即返回:标准输入接到空设备," +
"标准输出与标准错误都追加写入日志文件(默认 <程序名>.out,可用 -o 指定)," +
"Unix 下进入新会话、无控制终端,Windows 下以 DETACHED_PROCESS 启动,关闭终端不会终止它。" +
"选项在命令之前解析,命令之后的参数(含 -i 之类)原样透传,不加 shell;" +
"命令自身的 -n/--dry-run 不会被 kopsc 截获;kopsc 自身的预览等选项写在命令之前或用 -- 分隔",
Usage: "<命令> [参数...]",
MinArgs: 1,
Variants: []Variant{
{
Match: Match{OS: "linux"},
Builtin: "proc.nohup",
Note: "Linux 内置实现(setsid 新会话 + 日志追加)",
},
{
Match: Match{OS: "darwin"},
Builtin: "proc.nohup",
Note: "macOS 内置实现(setsid 新会话 + 日志追加)",
},
{
Match: Match{OS: "windows"},
Builtin: "proc.nohup",
Note: "Windows 内置实现(DETACHED_PROCESS + 日志追加)",
},
},
},
{
ID: "pkg.refreshindex",
Group: "pkg",
@@ -413,32 +448,42 @@ func Builtin() *Registry {
}}
}
// runtimeVariants builds the platform matrix for a container command. Linux
// gets both podman and docker variants (resolution prefers podman when both
// exist); macOS and Windows only have Docker Desktop.
func runtimeVariants(args ...string) []Variant {
program := func(runtime string) string {
if runtime == "podman" {
return "podman"
// runtimeBuiltinVariants builds the platform matrix for a container command
// that kopsc implements itself. Builtin variants carry no argv; the builtin
// learns the runtime's program name from Variant.Runtime instead.
func runtimeBuiltinVariants(name string) []Variant {
makeVariant := func(osName, note string) Variant {
return Variant{
Match: Match{OS: osName, Runtime: "docker"},
Builtin: "docker." + name,
Runtime: "docker",
Note: note,
}
return "docker"
}
return []Variant{
makeVariant("linux", "Linux 上的 Docker 运行时,由 kopsc 内置实现"),
makeVariant("darwin", "macOS 上的 Docker Desktop 运行时,由 kopsc 内置实现"),
makeVariant("windows", "Windows 上的 Docker Desktop 运行时,由 kopsc 内置实现"),
}
}
makeVariant := func(osName, runtime, note string) Variant {
// runtimeVariants builds the platform matrix for a container command: docker
// on every platform (Docker Desktop on macOS and Windows).
func runtimeVariants(args ...string) []Variant {
makeVariant := func(osName, note string) Variant {
argv := make([]string, 0, 1+len(args))
argv = append(argv, program(runtime))
argv = append(argv, "docker")
argv = append(argv, args...)
return Variant{
Match: Match{OS: osName, Runtime: runtime},
Match: Match{OS: osName, Runtime: "docker"},
Argv: argv,
Note: note,
}
}
return []Variant{
makeVariant("linux", "podman", "Linux 上的 Podman 运行时"),
makeVariant("linux", "docker", "Linux 上的 Docker 运行时"),
makeVariant("darwin", "docker", "macOS 上的 Docker Desktop 运行时"),
makeVariant("windows", "docker", "Windows 上的 Docker Desktop 运行时"),
makeVariant("linux", "Linux 上的 Docker 运行时"),
makeVariant("darwin", "macOS 上的 Docker Desktop 运行时"),
makeVariant("windows", "Windows 上的 Docker Desktop 运行时"),
}
}
+5 -1
View File
@@ -14,7 +14,7 @@ type Match struct {
Arch string // runtime.GOARCH
Distro string // exact os-release ID, e.g. "centos"
DistroLike []string // os-release ID_LIKE ancestry, e.g. "rhel"
Runtime string // container runtime required on PATH, e.g. "podman"
Runtime string // container runtime required on PATH, e.g. "docker"
}
// Variant is one concrete implementation of a command.
@@ -27,6 +27,10 @@ type Variant struct {
// have no equivalent system utility (for example on Windows).
Builtin string
Note string // human-readable rationale shown by `kopsc explain`
// Runtime records which container runtime a builtin should drive when
// Argv cannot carry it (builtin variants have no argv). Resolution uses
// Match.Runtime; this field is only informational context for the builtin.
Runtime string
}
// Command is a platform-neutral intent, e.g. "docker.clearsafe".
+57 -13
View File
@@ -25,7 +25,7 @@ func TestBuiltinIDsStable(t *testing.T) {
"docker.prunebuilder", "docker.prunevolumes", "docker.clearall",
"docker.compose.up", "docker.compose.down", "docker.compose.ps", "docker.compose.logs",
"diagnose",
"disk.usage", "net.pids", "net.ports", "pkg.refreshindex",
"disk.usage", "net.pids", "net.ports", "proc.nohup", "pkg.refreshindex",
}
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
t.Fatalf("IDs() =\n%v\nwant\n%v", got, want)
@@ -38,12 +38,12 @@ func TestBuiltinClearsafeIsSafe(t *testing.T) {
if !ok {
t.Fatal("docker.clearsafe not found")
}
if len(cmd.Variants) < 4 {
if len(cmd.Variants) < 3 {
t.Fatalf("expected a variant per platform, got %d", len(cmd.Variants))
}
for _, variant := range cmd.Variants {
if len(variant.Argv) == 0 || (variant.Argv[0] != "docker" && variant.Argv[0] != "podman") {
if len(variant.Argv) == 0 || variant.Argv[0] != "docker" {
t.Errorf("unexpected program %v", variant.Argv)
}
if strings.Join(variant.Argv, " ") != variant.Argv[0]+" system prune -f" {
@@ -182,6 +182,32 @@ func TestDiskUsageIsBuiltinEverywhere(t *testing.T) {
}
}
func TestProcNohupIsBuiltinEverywhere(t *testing.T) {
reg := Builtin()
cmd, ok := reg.Lookup("proc.nohup")
if !ok {
t.Fatal("proc.nohup not found")
}
if cmd.RequiresYes {
t.Error("proc.nohup is not destructive and must not require --yes")
}
if cmd.MinArgs != 1 || cmd.Usage == "" {
t.Errorf("proc.nohup must require the command to run, got MinArgs=%d Usage=%q", cmd.MinArgs, cmd.Usage)
}
seen := map[string]bool{}
for _, variant := range cmd.Variants {
if variant.Builtin != "proc.nohup" || len(variant.Argv) != 0 || variant.Shell {
t.Errorf("variant for %s must be the proc.nohup builtin, got %+v", variant.Match.OS, variant)
}
seen[variant.Match.OS] = true
}
for _, osName := range []string{"linux", "darwin", "windows"} {
if !seen[osName] {
t.Errorf("proc.nohup lacks a %s variant", osName)
}
}
}
func TestDockerCommandsWellFormed(t *testing.T) {
reg := Builtin()
destructive := map[string]bool{
@@ -200,8 +226,14 @@ func TestDockerCommandsWellFormed(t *testing.T) {
"docker.pull": 1,
"docker.tag": 2,
"docker.save": 1,
"docker.load": 1,
"docker.cp": 2,
}
// Built from kopsc's own implementation rather than a fixed argv.
builtin := map[string]string{
"docker.save": "docker.save",
"docker.load": "docker.load",
}
for i := range reg.Commands {
cmd := &reg.Commands[i]
@@ -219,24 +251,36 @@ func TestDockerCommandsWellFormed(t *testing.T) {
t.Errorf("%s: missing Usage for MinArgs=%d", cmd.ID, cmd.MinArgs)
}
var podman, docker int
platforms := map[string]bool{}
for _, v := range cmd.Variants {
if want, ok := builtin[cmd.ID]; ok {
if v.Builtin != want || len(v.Argv) != 0 {
t.Errorf("%s: builtin variant = %q with argv %v, want builtin %q",
cmd.ID, v.Builtin, v.Argv, want)
continue
}
if v.Runtime != v.Match.Runtime || v.Runtime != "docker" {
t.Errorf("%s: variant runtime %q / match %q, want docker", cmd.ID, v.Runtime, v.Match.Runtime)
}
platforms[v.Match.OS] = true
continue
}
if len(v.Argv) == 0 {
t.Errorf("%s: docker command must have an argv variant", cmd.ID)
continue
}
switch v.Argv[0] {
case "podman":
podman++
case "docker":
docker++
if v.Argv[0] != "docker" {
t.Errorf("%s: unexpected program %q", cmd.ID, v.Argv[0])
}
if v.Match.Runtime != v.Argv[0] {
t.Errorf("%s: runtime %q does not match program %q", cmd.ID, v.Match.Runtime, v.Argv[0])
if v.Match.Runtime != "docker" {
t.Errorf("%s: runtime %q, want docker", cmd.ID, v.Match.Runtime)
}
platforms[v.Match.OS] = true
}
if podman != 1 || docker != 3 {
t.Errorf("%s: variants = %d podman / %d docker, want 1/3", cmd.ID, podman, docker)
for _, osName := range []string{"linux", "darwin", "windows"} {
if !platforms[osName] {
t.Errorf("%s: missing %s variant", cmd.ID, osName)
}
}
}
}