commit 7d9852958f503397ce25b4063b0ff9d1d9da7598 Author: Odboy Date: Sat Oct 31 13:01:39 2026 +0800 feat: 跨平台命令助手 kopsc 初始实现 - 意图命令按 OS/架构、Linux 发行版与容器运行时解析并执行对应底层命令 - 内置 docker.clearsafe、net.ports、pkg.refreshindex - --dry-run 预览、list/explain/platform/version 辅助命令 - 纯 Go 跨平台构建脚本 scripts/build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33e1632 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Build output +/bin/ +/dist/ + +# Go test / coverage artifacts +*.test +*.out +coverage.* + +# Editor / OS +.idea/ +.vscode/ +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..92b865f --- /dev/null +++ b/README.md @@ -0,0 +1,151 @@ +# kopsc + +跨平台命令助手。用一条平台无关的意图命令,在受支持的平台上执行对应的底层命令: + +```bash +$ kopsc docker clearsafe --dry-run +[dry-run] 未执行任何命令 +将执行: docker system prune -f +平台: linux/amd64, distro=centos, runtimes=docker +方式: 直接执行 +命中: 变体 #1(5 分)CentOS 上的 Docker 运行时 +依据: 匹配 os=linux, distro=centos (+3), runtime=docker (+2) +``` + +在 CentOS 上它执行 `docker system prune -f`;在装有 Podman 的 CentOS 上自动改走 +`podman system prune -f`;在 macOS / Windows 上同样有各自的变体。你只记住一个意图。 + +## 构建 + +本机构建: + +```bash +go build -o bin/kopsc ./cmd/kopsc +``` + +### 跨平台编译脚本 + +`scripts/build` 是纯 Go 编写的构建脚本,用 `go run` 运行,不依赖 make / bash,在 Windows、macOS、Linux 上行为一致: + +```bash +go run ./scripts/build # 默认编译全部 6 个目标到 dist/ +go run ./scripts/build -host # 只编译当前 OS/ARCH +go run ./scripts/build -targets linux/amd64,windows/arm64 +go run ./scripts/build -version 1.2.3 # 指定版本(默认 git describe --tags --always --dirty) +go run ./scripts/build -out bin -no-strip # 自定义输出目录、保留符号表 +``` + +默认目标矩阵:`linux/{amd64,arm64}`、`darwin/{amd64,arm64}`、`windows/{amd64,arm64}`。 +脚本统一设置 `CGO_ENABLED=0`(纯 Go 静态产物)与 `-trimpath`,产物命名为 +`kopsc---[.exe]`,任一目标失败即以非零码退出。 + +等价的单条命令写法: + +```bash +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath \ + -ldflags "-s -w -X main.version=1.0.0" -o dist/kopsc-linux-amd64 ./cmd/kopsc +``` + +## 用法 + +```bash +kopsc docker clearsafe # 直接执行当前平台对应的清理命令 +kopsc docker clearsafe --dry-run # 只打印将执行的命令与判定依据 +kopsc docker clearsafe -h # 该意图的说明、当前平台命中结果与选项 +kopsc docker clearsafe -- --filter until=24h # -- 之后的参数原样透传给底层命令 + +kopsc list # 全部命令及当前平台可用性 +kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因 +kopsc platform # 探测到的 OS/ARCH/发行版/运行时 +kopsc version # 版本与平台 +``` + +### 选项解析规则 + +叶子命令禁用了 cobra 的 flag 解析,自行处理: + +| 参数 | 行为 | +| --- | --- | +| `-n`, `--dry-run`, `--dry-run=true` | 置为预览模式,不执行 | +| `--dry-run=false` | 关闭预览模式 | +| `-h`, `--help` | 打印该命令的帮助 | +| `--` | 其后的所有参数原样透传给底层命令 | +| 其它任何参数 | 原样透传给底层命令(包括未知 flag) | + +因此底层命令的 flag 不会与 kopsc 自身的选项冲突。 + +## 内置命令 + +| 命令 | 说明 | 平台变体 | +| --- | --- | --- | +| `docker.clearsafe` | 安全清理容器/网络/构建缓存 | Linux(centos)+podman、Linux(centos)+docker、Linux+podman、Linux+docker、darwin+docker、windows+docker,均为 `system prune -f` | +| `net.ports` | 列出监听中的端口 | Linux `ss -tulnp`、macOS `lsof -iTCP -sTCP:LISTEN -nP`、Windows `netstat -ano` | +| `pkg.refreshindex` | 刷新包管理器索引 | centos `dnf makecache`、ID_LIKE rhel/fedora `dnf makecache`、ID_LIKE debian/ubuntu `apt-get update`、alpine `apk update`、macOS `brew update`、Windows `winget source update` | + +`docker clearsafe` 的安全契约:只加 `-f` 以便无人值守执行,**不**加 `-a`(不清理全部未使用镜像), +**不**加 `--volumes`(不删除卷),仅清理停止的容器、未使用的网络与悬空镜像。 + +## 平台识别 + +`kopsc platform` 展示探测结果,来源: + +- OS/ARCH:编译目标 `runtime.GOOS` / `runtime.GOARCH` +- Linux 发行版:`/etc/os-release` 的 `ID` 与 `ID_LIKE`(文件缺失时视为未知,不报错) +- 容器运行时:`exec.LookPath` 探测 `docker`、`podman` + +## 匹配规则 + +每个命令由若干**变体**组成,变体声明它适用的环境约束(`OS`、`Arch`、`Distro`、 +`DistroLike`、`Runtime`,留空表示不限制)。 + +1. 变体声明的每一个约束都必须满足,否则该变体被排除。 +2. 满足的变体按特异性打分:`Distro` 精确命中 +3,`ID_LIKE` 命中 +2,`Runtime` 命中 +2,无约束的通用变体 0 分。 +3. 取最高分。并列时先按运行时偏好(podman 优先于 docker),再按声明约束的数量(更具体者优先)。 +4. 仍并列则报歧义错误;无任何变体匹配则报无匹配错误,并提示 `kopsc explain <命令>`。 +5. 取 `--dry-run` 或 `explain` 可看到每个候选的得分与被拒原因。 + +例如 CentOS 上同时装了 docker 与 podman: + +```text +✓ #0 5 分 匹配 os=linux, distro=centos (+3), runtime=podman (+2) -> podman system prune -f +✓ #1 5 分 匹配 os=linux, distro=centos (+3), runtime=docker (+2) -> docker system prune -f +✓ #2 2 分 匹配 os=linux, runtime=podman (+2) -> podman system prune -f +✗ #4 0 分 os=darwin 不匹配(当前 linux) -> docker system prune -f +``` + +## 新增命令或平台变体 + +内置映射位于 [`internal/registry/builtin.go`](internal/registry/builtin.go)(纯 Go 注册表,无外部配置文件)。 +新增一个变体即向对应 `Command.Variants` 追加一项: + +```go +{ + Match: Match{OS: "linux", Distro: "rocky", Runtime: "docker"}, + Argv: []string{"docker", "system", "prune", "-f"}, + Note: "Rocky Linux 上的 Docker", +}, +``` + +- `Argv[0]` 是程序,其余是参数;需要 shell 语义时设 `Shell: true`(Linux/macOS 用 `sh -c`,Windows 用 `cmd /c`)。 +- 新增命令时给 `Command` 填 `ID`(点号分隔,决定 CLI 层级)、`Summary`、`Action`,别名不得重复。 +- 提交前跑 `go test ./...`:`registry.Validate()` 与快照测试会拦截重复 ID/别名、空变体、空 argv。 + +## 开发 + +```bash +gofmt -l . # 应无输出 +go vet ./... +go test ./... +``` + +包结构: + +```text +cmd/kopsc 入口,注入版本号 +internal/platform 环境探测(可注入,便于测试) +internal/registry 命令模型、校验、内置注册表 +internal/resolve 变体打分与选择 +internal/executor 进程执行、dry-run、退出码透传 +internal/cli cobra 命令树与辅助命令 +scripts/build 跨平台构建脚本(go run ./scripts/build) +``` diff --git a/cmd/kopsc/main.go b/cmd/kopsc/main.go new file mode 100644 index 0000000..45fb54d --- /dev/null +++ b/cmd/kopsc/main.go @@ -0,0 +1,18 @@ +// Command kopsc is a cross-platform command assistant: one intent, one +// platform-appropriate command. +package main + +import ( + "os" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/cli" +) + +// version can be overridden at build time: +// +// go build -ldflags "-X main.version=1.2.3" -o bin/kopsc ./cmd/kopsc +var version = "dev" + +func main() { + os.Exit(cli.New(version).Execute(os.Args[1:])) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0195ab1 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module gitea.odboy.cn/odboy/kenaito-kopsc + +go 1.23.2 + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.9 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6ee3e0 --- /dev/null +++ b/go.sum @@ -0,0 +1,10 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/cli/cli.go b/internal/cli/cli.go new file mode 100644 index 0000000..fd506d9 --- /dev/null +++ b/internal/cli/cli.go @@ -0,0 +1,276 @@ +// Package cli builds the kopsc command tree from the registry. +package cli + +import ( + "context" + "fmt" + "io" + "os" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/executor" + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform" + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry" + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/resolve" +) + +// App wires a registry and a detected environment to a cobra command tree. +type App struct { + Registry *registry.Registry + Info platform.Info + Version string + + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer +} + +// New returns an App for the running process. +func New(version string) *App { + return &App{ + Registry: registry.Builtin(), + Info: platform.System().Detect(), + Version: version, + Stdin: os.Stdin, + Stdout: os.Stdout, + Stderr: os.Stderr, + } +} + +// Execute runs the command tree and returns the process exit code. +func (a *App) Execute(args []string) int { + root := a.Root() + root.SetArgs(args) + if err := root.Execute(); err != nil { + fmt.Fprintln(a.Stderr, "kopsc: "+err.Error()) + return executor.ExitCode(err) + } + return 0 +} + +// Root builds the full cobra command tree. +func (a *App) Root() *cobra.Command { + root := &cobra.Command{ + Use: "kopsc", + Short: "跨平台命令助手:用统一的意图命令在各平台执行对应命令", + Long: "kopsc 用平台无关的意图命令(如 docker clearsafe),根据操作系统、发行版与已安装的运行时,选择并执行对应的底层命令。", + Version: a.Version, + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + } + root.SetOut(a.Stdout) + root.SetErr(a.Stderr) + root.SetIn(a.Stdin) + + for i := range a.Registry.Commands { + a.addRegistryCommand(root, &a.Registry.Commands[i]) + } + + root.AddCommand(a.newListCmd(), a.newExplainCmd(), a.newPlatformCmd(), a.newVersionCmd()) + return root +} + +// addRegistryCommand turns "docker.clearsafe" into `kopsc docker clearsafe`, +// reusing intermediate commands for shared ID prefixes. +func (a *App) addRegistryCommand(root *cobra.Command, cmd *registry.Command) { + parts := strings.Split(cmd.ID, ".") + parent := root + + for _, group := range parts[:len(parts)-1] { + child := findChild(parent, group) + if child == nil { + groupName := group + child = &cobra.Command{ + Use: groupName, + Short: groupName + " 相关命令", + RunE: func(c *cobra.Command, args []string) error { + return c.Help() + }, + } + parent.AddCommand(child) + } + parent = child + } + + leaf := a.newLeaf(cmd, parts[len(parts)-1]) + parent.AddCommand(leaf) +} + +func findChild(parent *cobra.Command, name string) *cobra.Command { + for _, child := range parent.Commands() { + if child.Name() == name { + return child + } + } + return nil +} + +func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command { + leaf := &cobra.Command{ + Use: name + " [--dry-run] [-h] [-- 透传参数...]", + Short: cmd.Summary, + Long: cmd.Summary + "\n\n动作: " + cmd.Action, + DisableFlagParsing: true, + RunE: func(c *cobra.Command, args []string) error { + dryRun, extra, help := parseLeafArgs(args) + if help { + a.printLeafHelp(c.OutOrStdout(), cmd) + return nil + } + + result, err := resolve.Resolve(cmd, a.Info) + if err != nil { + return fmt.Errorf("%w(可用 `kopsc explain %s` 查看全部候选)", err, cmd.ID) + } + + plan := executor.Plan{Result: result, ExtraArgs: extra} + return plan.Run(context.Background(), executor.Options{ + DryRun: dryRun, + Stdin: a.Stdin, + Stdout: a.Stdout, + Stderr: a.Stderr, + }) + }, + } + return leaf +} + +// parseLeafArgs splits leaf arguments: kopsc-level flags before the first `--` +// are consumed, everything else (including `--` and after) is forwarded. +func parseLeafArgs(args []string) (dryRun bool, extra []string, help bool) { + passthrough := false + for _, arg := range args { + if passthrough { + extra = append(extra, arg) + continue + } + switch { + case arg == "--": + passthrough = true + case arg == "-n" || arg == "--dry-run" || arg == "--dry-run=true": + dryRun = true + case arg == "--dry-run=false": + dryRun = false + case arg == "-h" || arg == "--help": + help = true + default: + extra = append(extra, arg) + } + } + return dryRun, extra, help +} + +func (a *App) printLeafHelp(w io.Writer, cmd *registry.Command) { + invocation := "kopsc " + strings.ReplaceAll(cmd.ID, ".", " ") + fmt.Fprintf(w, "用法: %s [--dry-run] [-h] [-- 透传参数...]\n\n", invocation) + fmt.Fprintf(w, "%s\n\n", cmd.Summary) + fmt.Fprintf(w, "动作: %s\n\n", cmd.Action) + fmt.Fprintf(w, "当前平台: %s\n", resolve.DescribeInfo(a.Info)) + + if result, err := resolve.Resolve(cmd, a.Info); err == nil { + fmt.Fprintf(w, "将执行: %s\n\n", strings.Join(result.Variant.Argv, " ")) + } else { + fmt.Fprintf(w, "当前平台不可用: %v\n\n", err) + } + + fmt.Fprint(w, "选项:\n") + fmt.Fprint(w, " -n, --dry-run 仅打印将执行的命令与判定依据,不执行\n") + fmt.Fprint(w, " -h, --help 显示本帮助\n") + fmt.Fprint(w, " -- 其后的参数原样透传给底层命令\n") +} + +func (a *App) newListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "列出全部命令及其在当前平台的可用性", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + tw := tabwriter.NewWriter(a.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(tw, "命令\t状态\t将执行") + for i := range a.Registry.Commands { + entry := &a.Registry.Commands[i] + status, planned := "可用", "-" + if result, err := resolve.Resolve(entry, a.Info); err == nil { + planned = strings.Join(result.Variant.Argv, " ") + } else { + status = "不可用" + } + fmt.Fprintf(tw, "%s\t%s\t%s\n", entry.ID, status, planned) + } + return tw.Flush() + }, + } +} + +func (a *App) newExplainCmd() *cobra.Command { + return &cobra.Command{ + Use: "explain <命令>", + Short: "解释某条命令在当前平台的匹配过程", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + entry, ok := a.Registry.Lookup(args[0]) + if !ok { + return fmt.Errorf("未知命令 %q", args[0]) + } + + w := a.Stdout + fmt.Fprintf(w, "命令: %s\n", entry.ID) + fmt.Fprintf(w, "说明: %s\n", entry.Summary) + fmt.Fprintf(w, "动作: %s\n", entry.Action) + fmt.Fprintf(w, "平台: %s\n\n", resolve.DescribeInfo(a.Info)) + + result, err := resolve.Resolve(entry, a.Info) + if err != nil { + fmt.Fprintf(w, "结果: 当前平台不可用 — %v\n\n", err) + } else { + fmt.Fprintf(w, "结果: 命中变体 #%d(%d 分)→ %s\n\n", + result.VariantIdx, result.Score, strings.Join(result.Variant.Argv, " ")) + } + + fmt.Fprintln(w, "候选:") + for _, c := range result.Candidates { + mark := "✗" + if c.Matched { + mark = "✓" + } + note := "" + if c.Variant.Note != "" { + note = " # " + c.Variant.Note + } + fmt.Fprintf(w, " %s #%d %d 分 %s -> %s%s\n", + mark, c.Index, c.Score, c.Reason, strings.Join(c.Variant.Argv, " "), note) + } + return nil + }, + } +} + +func (a *App) newPlatformCmd() *cobra.Command { + return &cobra.Command{ + Use: "platform", + Short: "显示探测到的当前平台信息", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + _, err := io.WriteString(a.Stdout, a.Info.String()) + return err + }, + } +} + +func (a *App) newVersionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "显示版本与平台", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + _, err := fmt.Fprintf(a.Stdout, "kopsc %s %s/%s\n", a.Version, a.Info.OS, a.Info.Arch) + return err + }, + } +} diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go new file mode 100644 index 0000000..5b1eece --- /dev/null +++ b/internal/cli/cli_test.go @@ -0,0 +1,268 @@ +package cli + +import ( + "bytes" + "os" + "strings" + "testing" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform" + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry" +) + +func centos(runtimes ...string) platform.Info { + return platform.Info{ + OS: "linux", Arch: "amd64", + Distro: "centos", DistroLike: []string{"rhel", "fedora"}, + Runtimes: runtimes, + } +} + +func testApp(info platform.Info) (*App, *bytes.Buffer, *bytes.Buffer) { + stdout, stderr := &bytes.Buffer{}, &bytes.Buffer{} + return &App{ + Registry: registry.Builtin(), + Info: info, + Version: "test", + Stdin: strings.NewReader(""), + Stdout: stdout, + Stderr: stderr, + }, stdout, stderr +} + +func TestLeafDryRun(t *testing.T) { + app, stdout, stderr := testApp(centos("docker")) + + code := app.Execute([]string{"docker", "clearsafe", "--dry-run"}) + if code != 0 { + t.Fatalf("exit code = %d, stderr=%s", code, stderr.String()) + } + out := stdout.String() + for _, want := range []string{"[dry-run]", "docker system prune -f", "CentOS 上的 Docker 运行时", "依据"} { + if !strings.Contains(out, want) { + t.Errorf("dry-run output missing %q:\n%s", want, out) + } + } +} + +func TestLeafDryRunPrefersPodman(t *testing.T) { + app, stdout, _ := testApp(centos("docker", "podman")) + + if code := app.Execute([]string{"docker", "clearsafe", "--dry-run"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + if !strings.Contains(stdout.String(), "podman system prune -f") { + t.Errorf("expected podman variant:\n%s", stdout.String()) + } + if strings.Contains(stdout.String(), "docker system prune -f") { + t.Errorf("docker variant should not be chosen when podman is present:\n%s", stdout.String()) + } +} + +func TestLeafHelp(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + if code := app.Execute([]string{"docker", "clearsafe", "-h"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + out := stdout.String() + for _, want := range []string{"用法: kopsc docker clearsafe", "安全清理", "--dry-run", "将执行: docker system prune -f"} { + if !strings.Contains(out, want) { + t.Errorf("help output missing %q:\n%s", want, out) + } + } +} + +func TestLeafPassesThroughArgs(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + code := app.Execute([]string{"net", "ports", "--dry-run", "--", "-l"}) + if code != 0 { + t.Fatalf("exit code = %d", code) + } + if !strings.Contains(stdout.String(), "ss -tulnp -l") { + t.Errorf("passthrough args missing:\n%s", stdout.String()) + } +} + +func TestLeafNoMatch(t *testing.T) { + app, _, stderr := testApp(centos()) // no container runtime + + code := app.Execute([]string{"docker", "clearsafe"}) + if code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "不支持当前平台") { + t.Errorf("stderr lacks explanation:\n%s", stderr.String()) + } + if !strings.Contains(stderr.String(), "kopsc explain") { + t.Errorf("stderr lacks hint:\n%s", stderr.String()) + } +} + +func TestList(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + if code := app.Execute([]string{"list"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + out := stdout.String() + for _, want := range []string{"docker.clearsafe", "net.ports", "pkg.refreshindex", "可用", "docker system prune -f"} { + if !strings.Contains(out, want) { + t.Errorf("list output missing %q:\n%s", want, out) + } + } +} + +func TestListMarksUnavailable(t *testing.T) { + app, stdout, _ := testApp(platform.Info{OS: "plan9", Arch: "mips"}) + + if code := app.Execute([]string{"list"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + if !strings.Contains(stdout.String(), "不可用") { + t.Errorf("unavailable commands not marked:\n%s", stdout.String()) + } +} + +func TestExplain(t *testing.T) { + app, stdout, _ := testApp(centos("docker", "podman")) + + if code := app.Execute([]string{"explain", "docker.clearsafe"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + out := stdout.String() + for _, want := range []string{ + "命令: docker.clearsafe", + "平台:", + "命中变体 #0", + "候选:", + "✓", + "✗", + "podman system prune -f", + "distro=centos", + } { + if !strings.Contains(out, want) { + t.Errorf("explain output missing %q:\n%s", want, out) + } + } +} + +func TestExplainUnavailableStillListsCandidates(t *testing.T) { + app, stdout, _ := testApp(centos()) // no runtime + + if code := app.Execute([]string{"explain", "docker.clearsafe"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + out := stdout.String() + if !strings.Contains(out, "当前平台不可用") { + t.Errorf("missing unavailable marker:\n%s", out) + } + if strings.Count(out, "✗") != len(registry.Builtin().Commands[0].Variants) { + t.Errorf("expected every candidate to be listed:\n%s", out) + } +} + +func TestExplainUnknownCommand(t *testing.T) { + app, _, stderr := testApp(centos("docker")) + + if code := app.Execute([]string{"explain", "nope"}); code != 1 { + t.Fatalf("exit code = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "未知命令") { + t.Errorf("stderr = %s", stderr.String()) + } +} + +func TestPlatform(t *testing.T) { + app, stdout, _ := testApp(centos("docker", "podman")) + + if code := app.Execute([]string{"platform"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + out := stdout.String() + for _, want := range []string{"linux", "amd64", "centos", "rhel fedora", "docker, podman"} { + if !strings.Contains(out, want) { + t.Errorf("platform output missing %q:\n%s", want, out) + } + } +} + +func TestVersion(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + if code := app.Execute([]string{"version"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + if !strings.Contains(stdout.String(), "kopsc test linux/amd64") { + t.Errorf("version output = %s", stdout.String()) + } +} + +func TestGroupHelp(t *testing.T) { + app, stdout, _ := testApp(centos("docker")) + + if code := app.Execute([]string{"docker"}); code != 0 { + t.Fatalf("exit code = %d", code) + } + if !strings.Contains(stdout.String(), "clearsafe") { + t.Errorf("group help missing subcommand:\n%s", stdout.String()) + } +} + +func TestParseLeafArgs(t *testing.T) { + tests := []struct { + name string + args []string + wantDryRun bool + wantExtra []string + wantHelp bool + }{ + {"empty", nil, false, nil, false}, + {"dry-run short", []string{"-n"}, true, nil, false}, + {"dry-run long", []string{"--dry-run"}, true, nil, false}, + {"dry-run false", []string{"--dry-run=false"}, false, nil, false}, + {"help", []string{"-h"}, false, nil, true}, + {"passthrough", []string{"--", "--dry-run", "-h"}, false, []string{"--dry-run", "-h"}, false}, + {"unknown flags forwarded", []string{"--filter", "until=1h"}, false, []string{"--filter", "until=1h"}, false}, + {"mixed", []string{"--dry-run", "arg", "--", "-n"}, true, []string{"arg", "-n"}, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + dryRun, extra, help := parseLeafArgs(tc.args) + if dryRun != tc.wantDryRun || help != tc.wantHelp { + t.Errorf("dryRun=%v help=%v, want %v/%v", dryRun, help, tc.wantDryRun, tc.wantHelp) + } + if strings.Join(extra, "|") != strings.Join(tc.wantExtra, "|") { + t.Errorf("extra = %v, want %v", extra, tc.wantExtra) + } + }) + } +} + +// TestCLIHelperProcess is re-executed as a child to verify exit-code passthrough. +func TestCLIHelperProcess(t *testing.T) { + if os.Getenv("KOPSC_CLI_HELPER") != "1" { + return + } + os.Exit(9) +} + +func TestExecutionPreservesExitCode(t *testing.T) { + t.Setenv("KOPSC_CLI_HELPER", "1") + + app, _, _ := testApp(centos("docker")) + app.Registry = ®istry.Registry{Commands: []registry.Command{{ + ID: "demo.fail", + Summary: "always fails", + Variants: []registry.Variant{{ + Match: registry.Match{OS: "linux"}, + Argv: []string{os.Args[0], "-test.run=TestCLIHelperProcess"}, + }}, + }}} + + if code := app.Execute([]string{"demo", "fail"}); code != 9 { + t.Fatalf("exit code = %d, want 9", code) + } +} diff --git a/internal/executor/executor.go b/internal/executor/executor.go new file mode 100644 index 0000000..c3e6b96 --- /dev/null +++ b/internal/executor/executor.go @@ -0,0 +1,187 @@ +// Package executor turns a resolved variant into a process, with dry-run +// support and faithful stdio/exit-code passthrough. +package executor + +import ( + "context" + "errors" + "fmt" + "io" + "os/exec" + "runtime" + "strings" + "unicode" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/resolve" +) + +// Options controls how a Plan is executed. +type Options struct { + DryRun bool + Stdin io.Reader + Stdout io.Writer + Stderr io.Writer + Env []string // optional override of the child environment + Dir string // optional working directory +} + +// Plan is a resolved variant plus arguments forwarded from the caller. +type Plan struct { + Result resolve.Result + ExtraArgs []string +} + +// Argv returns the final argument vector: the variant's argv followed by the +// forwarded arguments. +func (p Plan) Argv() []string { + argv := make([]string, 0, len(p.Result.Variant.Argv)+len(p.ExtraArgs)) + argv = append(argv, p.Result.Variant.Argv...) + argv = append(argv, p.ExtraArgs...) + return argv +} + +// Display renders the command line the plan would run. +func (p Plan) Display() string { + argv := p.Argv() + if p.Result.Variant.Shell { + flag := "-c" + if runtime.GOOS == "windows" { + flag = "/c" + } + return shellName() + " " + flag + " " + quoteForShell(strings.Join(argv, " "), runtime.GOOS) + } + return strings.Join(argv, " ") +} + +// Explain writes why this variant was chosen and what will run. +func (p Plan) Explain(w io.Writer) { + if w == nil { + return + } + variant := p.Result.Variant + fmt.Fprintf(w, "将执行: %s\n", p.Display()) + fmt.Fprintf(w, "平台: %s\n", resolve.DescribeInfo(p.Result.Info)) + if variant.Shell { + fmt.Fprintf(w, "方式: shell (%s)\n", shellName()) + } else { + fmt.Fprintf(w, "方式: 直接执行\n") + } + note := variant.Note + if note == "" { + note = "无备注" + } + fmt.Fprintf(w, "命中: 变体 #%d(%d 分)%s\n", p.Result.VariantIdx, p.Result.Score, note) + if p.Result.VariantIdx >= 0 && p.Result.VariantIdx < len(p.Result.Candidates) { + fmt.Fprintf(w, "依据: %s\n", p.Result.Candidates[p.Result.VariantIdx].Reason) + } + if len(p.ExtraArgs) > 0 { + fmt.Fprintf(w, "透传: %s\n", strings.Join(p.ExtraArgs, " ")) + } +} + +// Run executes the plan. With DryRun it only prints the explanation. A child +// exiting non-zero is reported as *ExitError so callers can preserve the code. +func (p Plan) Run(ctx context.Context, opts Options) error { + if opts.Stdout == nil { + opts.Stdout = io.Discard + } + if opts.Stderr == nil { + opts.Stderr = io.Discard + } + if opts.DryRun { + fmt.Fprintln(opts.Stdout, "[dry-run] 未执行任何命令") + p.Explain(opts.Stdout) + return nil + } + + name, args := p.command() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Stdin = opts.Stdin + cmd.Stdout = opts.Stdout + cmd.Stderr = opts.Stderr + cmd.Env = opts.Env + cmd.Dir = opts.Dir + + 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("执行 %q 失败: %w", name, err) + } + return nil +} + +func (p Plan) command() (string, []string) { + argv := p.Argv() + if !p.Result.Variant.Shell { + return argv[0], argv[1:] + } + shell := shellName() + if runtime.GOOS == "windows" { + return shell, []string{"/c", joinForShell(argv, runtime.GOOS)} + } + return shell, []string{"-c", joinForShell(argv, runtime.GOOS)} +} + +func shellName() string { + if runtime.GOOS == "windows" { + return "cmd" + } + return "sh" +} + +// joinForShell joins argv with platform-appropriate quoting. +func joinForShell(argv []string, osName string) string { + parts := make([]string, len(argv)) + for i, arg := range argv { + parts[i] = quoteForShell(arg, osName) + } + return strings.Join(parts, " ") +} + +// quoteForShell leaves safe arguments untouched and quotes the rest. +func quoteForShell(arg, osName string) string { + if osName == "windows" { + if arg != "" && !strings.ContainsAny(arg, " \t\"&|<>^%") { + return arg + } + return `"` + strings.ReplaceAll(arg, `"`, `\"`) + `"` + } + + if arg != "" && strings.IndexFunc(arg, unsafeShellRune) < 0 { + return arg + } + return "'" + strings.ReplaceAll(arg, "'", `'\''`) + "'" +} + +func unsafeShellRune(r rune) bool { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + return false + } + return !strings.ContainsRune("-_.:/=@+,", r) +} + +// ExitError carries the child process exit code. +type ExitError struct { + Code int + Err error +} + +func (e *ExitError) Error() string { + return fmt.Sprintf("命令以状态码 %d 退出", e.Code) +} + +func (e *ExitError) Unwrap() error { return e.Err } + +// ExitCode maps an error to the process exit code kopsc should return. +func ExitCode(err error) int { + if err == nil { + return 0 + } + var exitErr *ExitError + if errors.As(err, &exitErr) { + return exitErr.Code + } + return 1 +} diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go new file mode 100644 index 0000000..be5ee56 --- /dev/null +++ b/internal/executor/executor_test.go @@ -0,0 +1,221 @@ +package executor + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform" + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry" + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/resolve" +) + +func samplePlan(argv ...string) Plan { + if len(argv) == 0 { + argv = []string{"docker", "system", "prune", "-f"} + } + return Plan{Result: resolve.Result{ + Command: ®istry.Command{ID: "docker.clearsafe", Summary: "安全清理"}, + Info: platform.Info{ + OS: "linux", Arch: "amd64", Distro: "centos", + DistroLike: []string{"rhel"}, Runtimes: []string{"docker"}, + }, + Variant: registry.Variant{Argv: argv, Note: "CentOS 上的 Docker 运行时"}, + VariantIdx: 0, + Score: 5, + Candidates: []resolve.Candidate{{ + Index: 0, Matched: true, Score: 5, + Reason: "匹配 os=linux, distro=centos (+3), runtime=docker (+2)", + }}, + }} +} + +func TestDryRunOnlyPrints(t *testing.T) { + var out bytes.Buffer + err := samplePlan().Run(context.Background(), Options{DryRun: true, Stdout: &out}) + if err != nil { + t.Fatalf("dry-run returned error: %v", err) + } + + text := out.String() + for _, want := range []string{ + "[dry-run]", + "docker system prune -f", + "linux/amd64", + "distro=centos", + "变体 #0", + "CentOS 上的 Docker 运行时", + "依据", + } { + if !strings.Contains(text, want) { + t.Errorf("dry-run output missing %q:\n%s", want, text) + } + } +} + +func TestDryRunNeverExecutes(t *testing.T) { + // A missing program must not matter in dry-run mode. + err := samplePlan("kopsc-program-that-does-not-exist").Run(context.Background(), Options{ + DryRun: true, + Stdout: io.Discard, + }) + if err != nil { + t.Fatalf("dry-run should not attempt execution: %v", err) + } +} + +func TestArgvAppendsExtraArgs(t *testing.T) { + plan := samplePlan("docker", "system", "prune", "-f") + plan.ExtraArgs = []string{"--filter", "until=24h"} + want := []string{"docker", "system", "prune", "-f", "--filter", "until=24h"} + if got := plan.Argv(); strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("Argv() = %v, want %v", got, want) + } + if !strings.Contains(plan.Display(), "--filter until=24h") { + t.Fatalf("Display() = %q", plan.Display()) + } +} + +// TestHelperProcess is re-executed as a child by the tests below. +func TestHelperProcess(t *testing.T) { + if os.Getenv("KOPSC_TEST_HELPER") != "1" { + return + } + fmt.Fprintln(os.Stdout, "helper-stdout", strings.Join(os.Args[1:], " ")) + fmt.Fprintln(os.Stderr, "helper-stderr") + os.Exit(7) +} + +func TestRunPropagatesStdioAndExitCode(t *testing.T) { + t.Setenv("KOPSC_TEST_HELPER", "1") + + plan := samplePlan(os.Args[0], "-test.run=TestHelperProcess", "--") + plan.ExtraArgs = []string{"forwarded"} + + var stdout, stderr bytes.Buffer + err := plan.Run(context.Background(), Options{Stdout: &stdout, Stderr: &stderr}) + if err == nil { + t.Fatal("expected a non-zero exit error") + } + var exitErr *ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("error type = %T, want *ExitError", err) + } + if exitErr.Code != 7 { + t.Errorf("exit code = %d, want 7", exitErr.Code) + } + if ExitCode(err) != 7 { + t.Errorf("ExitCode(err) = %d, want 7", ExitCode(err)) + } + if !strings.Contains(stdout.String(), "helper-stdout") { + t.Errorf("child stdout not forwarded:\n%s", stdout.String()) + } + if !strings.Contains(stdout.String(), "forwarded") { + t.Errorf("extra args not forwarded:\n%s", stdout.String()) + } + if !strings.Contains(stderr.String(), "helper-stderr") { + t.Errorf("child stderr not forwarded:\n%s", stderr.String()) + } +} + +func TestRunFindsProgramOnPATH(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell script fixture is not executable on Windows") + } + + dir := t.TempDir() + script := filepath.Join(dir, "docker") + body := "#!/bin/sh\necho fake-docker \"$@\"\nexit 3\n" + if err := os.WriteFile(script, []byte(body), 0o755); err != nil { + t.Fatalf("write fixture: %v", err) + } + t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) + + var out bytes.Buffer + err := samplePlan().Run(context.Background(), Options{Stdout: &out, Stderr: io.Discard}) + if ExitCode(err) != 3 { + t.Fatalf("exit code = %d, want 3 (err=%v)", ExitCode(err), err) + } + if !strings.Contains(out.String(), "fake-docker system prune -f") { + t.Errorf("fixture output missing:\n%s", out.String()) + } +} + +func TestRunNotFound(t *testing.T) { + err := samplePlan("kopsc-program-that-does-not-exist").Run(context.Background(), Options{ + Stdout: io.Discard, + }) + if err == nil { + t.Fatal("expected an error for a missing program") + } + if ExitCode(err) != 1 { + t.Errorf("ExitCode() = %d, want 1", ExitCode(err)) + } +} + +func TestShellVariant(t *testing.T) { + plan := samplePlan("echo", "hello from shell") + plan.Result.Variant.Shell = true + + var out bytes.Buffer + if err := plan.Run(context.Background(), Options{Stdout: &out, Stderr: io.Discard}); err != nil { + t.Fatalf("shell run failed: %v", err) + } + if !strings.Contains(out.String(), "hello from shell") { + t.Errorf("shell output missing:\n%s", out.String()) + } + + display := plan.Display() + wantShell := "sh -c" + if runtime.GOOS == "windows" { + wantShell = "cmd /c" + } + if !strings.HasPrefix(display, wantShell) { + t.Errorf("Display() = %q, want prefix %q", display, wantShell) + } +} + +func TestQuoteForShell(t *testing.T) { + unixCases := map[string]string{ + "plain": "plain", + "with space": "'with space'", + "it's": `'it'\''s'`, + "": "''", + "/usr/bin/x": "/usr/bin/x", + "--filter=a,b": "--filter=a,b", + } + for in, want := range unixCases { + if got := quoteForShell(in, "linux"); got != want { + t.Errorf("quoteForShell(%q, linux) = %q, want %q", in, got, want) + } + } + + windowsCases := map[string]string{ + "plain": "plain", + "with space": `"with space"`, + } + for in, want := range windowsCases { + if got := quoteForShell(in, "windows"); got != want { + t.Errorf("quoteForShell(%q, windows) = %q, want %q", in, got, want) + } + } +} + +func TestExitCode(t *testing.T) { + if got := ExitCode(nil); got != 0 { + t.Errorf("ExitCode(nil) = %d, want 0", got) + } + if got := ExitCode(errors.New("boom")); got != 1 { + t.Errorf("ExitCode(generic) = %d, want 1", got) + } + if got := ExitCode(&ExitError{Code: 42}); got != 42 { + t.Errorf("ExitCode(ExitError) = %d, want 42", got) + } +} diff --git a/internal/platform/platform.go b/internal/platform/platform.go new file mode 100644 index 0000000..d00581d --- /dev/null +++ b/internal/platform/platform.go @@ -0,0 +1,118 @@ +// Package platform detects the execution environment that kopsc uses to pick +// the most specific command variant: OS/arch, Linux distribution and available +// container runtimes. +package platform + +import ( + "bufio" + "bytes" + "os" + "os/exec" + "runtime" + "strings" +) + +// OSReleasePath is where Linux distributions publish their identity. +const OSReleasePath = "/etc/os-release" + +// Info is the detected environment. +type Info struct { + OS string // runtime.GOOS, e.g. "linux", "darwin", "windows" + Arch string // runtime.GOARCH, e.g. "amd64", "arm64" + Distro string // lower-case os-release ID, empty when unknown + DistroLike []string // lower-case os-release ID_LIKE entries + Runtimes []string // container runtimes found on PATH, e.g. "docker", "podman" +} + +// Probe gathers Info. Its dependencies are injectable so detection can be +// exercised without touching the real host. +type Probe struct { + GOOS string + GOARCH string + ReadFile func(string) ([]byte, error) + LookPath func(string) (string, error) + RuntimeCandidates []string +} + +// System returns a Probe wired to the real process environment. +func System() Probe { + return Probe{ + GOOS: runtime.GOOS, + GOARCH: runtime.GOARCH, + ReadFile: os.ReadFile, + LookPath: exec.LookPath, + RuntimeCandidates: []string{"docker", "podman"}, + } +} + +// Detect inspects the environment. A missing or unreadable os-release file is +// not an error: it simply leaves Distro empty, which resolution treats as +// "unknown distribution". +func (p Probe) Detect() Info { + info := Info{OS: p.GOOS, Arch: p.GOARCH} + + if p.GOOS == "linux" && p.ReadFile != nil { + if data, err := p.ReadFile(OSReleasePath); err == nil { + info.Distro, info.DistroLike = ParseOSRelease(data) + } + } + + candidates := p.RuntimeCandidates + if candidates == nil { + candidates = []string{"docker", "podman"} + } + if p.LookPath != nil { + for _, name := range candidates { + if _, err := p.LookPath(name); err == nil { + info.Runtimes = append(info.Runtimes, name) + } + } + } + + return info +} + +// ParseOSRelease extracts ID and ID_LIKE from os-release content. Values are +// lower-cased and unquoted; parsing never fails, malformed lines are skipped. +func ParseOSRelease(data []byte) (distro string, like []string) { + values := make(map[string]string) + scanner := bufio.NewScanner(bytes.NewReader(data)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + values[strings.TrimSpace(key)] = strings.Trim(strings.TrimSpace(value), `"'`) + } + + distro = strings.ToLower(strings.TrimSpace(values["ID"])) + for _, field := range strings.Fields(values["ID_LIKE"]) { + like = append(like, strings.ToLower(field)) + } + return distro, like +} + +// String renders Info for `kopsc platform`. +func (i Info) String() string { + var b strings.Builder + b.WriteString("os: " + i.OS + "\n") + b.WriteString("arch: " + i.Arch + "\n") + distro := i.Distro + if distro == "" { + distro = "-" + } + b.WriteString("distro: " + distro + "\n") + if len(i.DistroLike) > 0 { + b.WriteString("like: " + strings.Join(i.DistroLike, " ") + "\n") + } + runtimes := "-" + if len(i.Runtimes) > 0 { + runtimes = strings.Join(i.Runtimes, ", ") + } + b.WriteString("runtimes: " + runtimes + "\n") + return b.String() +} diff --git a/internal/platform/platform_test.go b/internal/platform/platform_test.go new file mode 100644 index 0000000..fddd727 --- /dev/null +++ b/internal/platform/platform_test.go @@ -0,0 +1,160 @@ +package platform + +import ( + "errors" + "reflect" + "strings" + "testing" +) + +const centosStream9 = `NAME="CentOS Stream" +VERSION="9" +ID="centos" +ID_LIKE="rhel fedora" +VERSION_ID="9" +PLATFORM_ID="platform:el9" +` + +const ubuntu2404 = `PRETTY_NAME="Ubuntu 24.04 LTS" +NAME="Ubuntu" +VERSION_ID="24.04" +ID=ubuntu +ID_LIKE=debian +` + +const alpine = `NAME="Alpine Linux" +ID=alpine +VERSION_ID=3.20.3 +` + +func TestParseOSRelease(t *testing.T) { + tests := []struct { + name string + input string + wantDistro string + wantLike []string + }{ + {"centos stream 9", centosStream9, "centos", []string{"rhel", "fedora"}}, + {"ubuntu 24.04", ubuntu2404, "ubuntu", []string{"debian"}}, + {"alpine without ID_LIKE", alpine, "alpine", nil}, + {"comments and blank lines", "# a comment\n\nID=rocky\nID_LIKE='rhel centos'\n", "rocky", []string{"rhel", "centos"}}, + {"empty input", "", "", nil}, + {"malformed line", "ID\nID=debian\n", "debian", nil}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + distro, like := ParseOSRelease([]byte(tc.input)) + if distro != tc.wantDistro { + t.Errorf("distro = %q, want %q", distro, tc.wantDistro) + } + if !reflect.DeepEqual(like, tc.wantLike) { + t.Errorf("like = %v, want %v", like, tc.wantLike) + } + }) + } +} + +func TestDetect(t *testing.T) { + tests := []struct { + name string + goos string + goarch string + osRelease string + osReleaseErr error + present []string + want Info + }{ + { + name: "linux centos with docker", + goos: "linux", + goarch: "amd64", + osRelease: centosStream9, + present: []string{"docker"}, + want: Info{ + OS: "linux", Arch: "amd64", + Distro: "centos", DistroLike: []string{"rhel", "fedora"}, + Runtimes: []string{"docker"}, + }, + }, + { + name: "linux centos with podman and docker", + goos: "linux", + goarch: "arm64", + osRelease: centosStream9, + present: []string{"docker", "podman"}, + want: Info{ + OS: "linux", Arch: "arm64", + Distro: "centos", DistroLike: []string{"rhel", "fedora"}, + Runtimes: []string{"docker", "podman"}, + }, + }, + { + name: "linux without os-release", + goos: "linux", + goarch: "amd64", + osReleaseErr: errors.New("no such file"), + want: Info{OS: "linux", Arch: "amd64"}, + }, + { + name: "darwin ignores os-release", + goos: "darwin", + goarch: "arm64", + present: []string{"docker"}, + want: Info{OS: "darwin", Arch: "arm64", Runtimes: []string{"docker"}}, + }, + { + name: "windows has no runtimes on PATH", + goos: "windows", + goarch: "amd64", + want: Info{OS: "windows", Arch: "amd64"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + probe := Probe{ + GOOS: tc.goos, + GOARCH: tc.goarch, + ReadFile: func(path string) ([]byte, error) { + if path != OSReleasePath { + t.Fatalf("unexpected path %q", path) + } + if tc.osReleaseErr != nil { + return nil, tc.osReleaseErr + } + return []byte(tc.osRelease), nil + }, + LookPath: func(name string) (string, error) { + for _, p := range tc.present { + if p == name { + return "/usr/bin/" + name, nil + } + } + return "", errors.New("not found") + }, + RuntimeCandidates: []string{"docker", "podman"}, + } + + got := probe.Detect() + if !reflect.DeepEqual(got, tc.want) { + t.Errorf("Info = %+v, want %+v", got, tc.want) + } + }) + } +} + +func TestInfoString(t *testing.T) { + info := Info{OS: "linux", Arch: "amd64", Distro: "centos", DistroLike: []string{"rhel", "fedora"}, Runtimes: []string{"docker"}} + out := info.String() + for _, want := range []string{"linux", "amd64", "centos", "rhel fedora", "docker"} { + if !strings.Contains(out, want) { + t.Errorf("String() missing %q:\n%s", want, out) + } + } + + empty := Info{OS: "windows", Arch: "amd64"}.String() + if !strings.Contains(empty, "distro: -") || !strings.Contains(empty, "runtimes: -") { + t.Errorf("String() should render placeholders for empty fields:\n%s", empty) + } +} diff --git a/internal/registry/builtin.go b/internal/registry/builtin.go new file mode 100644 index 0000000..9e50e6b --- /dev/null +++ b/internal/registry/builtin.go @@ -0,0 +1,109 @@ +package registry + +// Builtin returns the registry compiled into kopsc. Every variant is explicit +// about the platform it targets; resolution picks the most specific match. +func Builtin() *Registry { + return &Registry{Commands: []Command{ + { + ID: "docker.clearsafe", + Aliases: []string{"docker.prunesafe"}, + Group: "docker", + Summary: "安全清理未使用的容器、网络与构建缓存", + Action: "执行容器运行时的默认清理作用域:不清理全部未使用镜像(-a)," + + "不删除卷(--volumes),仅移除停止的容器、未使用的网络和悬空镜像", + Variants: []Variant{ + { + Match: Match{OS: "linux", Distro: "centos", Runtime: "podman"}, + Argv: []string{"podman", "system", "prune", "-f"}, + Note: "CentOS 上的 Podman 运行时", + }, + { + Match: Match{OS: "linux", Distro: "centos", Runtime: "docker"}, + Argv: []string{"docker", "system", "prune", "-f"}, + Note: "CentOS 上的 Docker 运行时", + }, + { + Match: Match{OS: "linux", Runtime: "podman"}, + Argv: []string{"podman", "system", "prune", "-f"}, + Note: "任意 Linux 发行版上的 Podman 运行时", + }, + { + Match: Match{OS: "linux", Runtime: "docker"}, + Argv: []string{"docker", "system", "prune", "-f"}, + Note: "任意 Linux 发行版上的 Docker 运行时", + }, + { + Match: Match{OS: "darwin", Runtime: "docker"}, + Argv: []string{"docker", "system", "prune", "-f"}, + Note: "macOS 上的 Docker Desktop 运行时", + }, + { + Match: Match{OS: "windows", Runtime: "docker"}, + Argv: []string{"docker", "system", "prune", "-f"}, + Note: "Windows 上的 Docker Desktop 运行时", + }, + }, + }, + { + ID: "net.ports", + Group: "net", + Summary: "列出本机正在监听的 TCP/UDP 端口", + Action: "以只读方式列出监听中的套接字及持有进程", + Variants: []Variant{ + { + Match: Match{OS: "linux"}, + Argv: []string{"ss", "-tulnp"}, + Note: "Linux 使用 iproute2 的 ss", + }, + { + Match: Match{OS: "darwin"}, + Argv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"}, + Note: "macOS 使用 lsof", + }, + { + Match: Match{OS: "windows"}, + Argv: []string{"netstat", "-ano"}, + Note: "Windows 使用 netstat", + }, + }, + }, + { + ID: "pkg.refreshindex", + Group: "pkg", + Summary: "刷新系统包管理器的软件源索引", + Action: "只更新元数据索引,不升级或安装任何软件包", + Variants: []Variant{ + { + Match: Match{OS: "linux", Distro: "centos"}, + Argv: []string{"dnf", "makecache"}, + Note: "CentOS 使用 dnf", + }, + { + Match: Match{OS: "linux", DistroLike: []string{"rhel", "fedora"}}, + Argv: []string{"dnf", "makecache"}, + Note: "RHEL/Fedora 系发行版使用 dnf", + }, + { + Match: Match{OS: "linux", DistroLike: []string{"debian", "ubuntu"}}, + Argv: []string{"apt-get", "update"}, + Note: "Debian/Ubuntu 系发行版使用 apt-get", + }, + { + Match: Match{OS: "linux", Distro: "alpine"}, + Argv: []string{"apk", "update"}, + Note: "Alpine 使用 apk", + }, + { + Match: Match{OS: "darwin"}, + Argv: []string{"brew", "update"}, + Note: "macOS 使用 Homebrew", + }, + { + Match: Match{OS: "windows"}, + Argv: []string{"winget", "source", "update"}, + Note: "Windows 使用 winget", + }, + }, + }, + }} +} diff --git a/internal/registry/registry.go b/internal/registry/registry.go new file mode 100644 index 0000000..567ffb9 --- /dev/null +++ b/internal/registry/registry.go @@ -0,0 +1,120 @@ +// Package registry holds kopsc's built-in command registry: platform-neutral +// intents, each with one or more platform-specific variants. +package registry + +import ( + "fmt" + "strings" +) + +// Match constrains a Variant to environments. An empty field means "any". +// A Variant with a completely empty Match is a generic fallback. +type Match struct { + OS string // runtime.GOOS + 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" +} + +// Variant is one concrete implementation of a command. +type Variant struct { + Match Match + Argv []string // program followed by arguments + Shell bool // run through the platform shell instead of exec'ing Argv[0] + Note string // human-readable rationale shown by `kopsc explain` +} + +// Command is a platform-neutral intent, e.g. "docker.clearsafe". +type Command struct { + ID string // hierarchical, dot-separated: "docker.clearsafe" + Aliases []string + Summary string + Group string // CLI grouping, e.g. "docker" + Action string // what the command does, including its safety contract + Variants []Variant +} + +// Registry is an ordered set of commands. +type Registry struct { + Commands []Command +} + +// Validate reports structural problems: empty or duplicate identifiers, +// empty aliases, commands without variants and variants without an argv. +func (r *Registry) Validate() error { + seenIDs := make(map[string]bool) + seenAliases := make(map[string]string) + + for i, cmd := range r.Commands { + if strings.TrimSpace(cmd.ID) == "" { + return fmt.Errorf("command #%d: empty ID", i) + } + if seenIDs[cmd.ID] { + return fmt.Errorf("command #%d: duplicate ID %q", i, cmd.ID) + } + seenIDs[cmd.ID] = true + + if strings.TrimSpace(cmd.Summary) == "" { + return fmt.Errorf("command %q: empty summary", cmd.ID) + } + if len(cmd.Variants) == 0 { + return fmt.Errorf("command %q: no variants", cmd.ID) + } + + for _, alias := range cmd.Aliases { + if strings.TrimSpace(alias) == "" { + return fmt.Errorf("command %q: empty alias", cmd.ID) + } + if owner, ok := seenAliases[alias]; ok { + return fmt.Errorf("command %q: alias %q already used by %q", cmd.ID, alias, owner) + } + seenAliases[alias] = cmd.ID + } + + for j, variant := range cmd.Variants { + if len(variant.Argv) == 0 || strings.TrimSpace(variant.Argv[0]) == "" { + return fmt.Errorf("command %q variant #%d: empty argv", cmd.ID, j) + } + if err := variant.Match.validate(); err != nil { + return fmt.Errorf("command %q variant #%d: %w", cmd.ID, j, err) + } + } + } + + return nil +} + +func (m Match) validate() error { + for _, field := range m.DistroLike { + if strings.TrimSpace(field) == "" { + return fmt.Errorf("empty DistroLike entry") + } + } + return nil +} + +// Lookup finds a command by ID or alias. +func (r *Registry) Lookup(idOrAlias string) (*Command, bool) { + for i := range r.Commands { + cmd := &r.Commands[i] + if cmd.ID == idOrAlias { + return cmd, true + } + for _, alias := range cmd.Aliases { + if alias == idOrAlias { + return cmd, true + } + } + } + return nil, false +} + +// IDs returns every command ID in registry order. +func (r *Registry) IDs() []string { + ids := make([]string, 0, len(r.Commands)) + for _, cmd := range r.Commands { + ids = append(ids, cmd.ID) + } + return ids +} diff --git a/internal/registry/registry_test.go b/internal/registry/registry_test.go new file mode 100644 index 0000000..fd68766 --- /dev/null +++ b/internal/registry/registry_test.go @@ -0,0 +1,141 @@ +package registry + +import ( + "reflect" + "strings" + "testing" +) + +func TestBuiltinIsValid(t *testing.T) { + reg := Builtin() + if err := reg.Validate(); err != nil { + t.Fatalf("Builtin() is invalid: %v", err) + } +} + +func TestBuiltinIDsStable(t *testing.T) { + reg := Builtin() + want := []string{"docker.clearsafe", "net.ports", "pkg.refreshindex"} + if got := reg.IDs(); !reflect.DeepEqual(got, want) { + t.Fatalf("IDs() = %v, want %v", got, want) + } +} + +func TestBuiltinClearsafeIsSafe(t *testing.T) { + reg := Builtin() + cmd, ok := reg.Lookup("docker.clearsafe") + if !ok { + t.Fatal("docker.clearsafe not found") + } + if len(cmd.Variants) < 4 { + 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") { + t.Errorf("unexpected program %v", variant.Argv) + } + if strings.Join(variant.Argv, " ") != variant.Argv[0]+" system prune -f" { + t.Errorf("clearsafe must stay scoped to `system prune -f`, got %v", variant.Argv) + } + for _, forbidden := range []string{"-a", "--all", "--volumes", "-v", "--force=false"} { + for _, arg := range variant.Argv { + if arg == forbidden { + t.Errorf("variant %v must not pass %q", variant.Argv, forbidden) + } + } + } + } +} + +func TestLookupAlias(t *testing.T) { + reg := Builtin() + cmd, ok := reg.Lookup("docker.prunesafe") + if !ok { + t.Fatal("alias lookup failed") + } + if cmd.ID != "docker.clearsafe" { + t.Fatalf("alias resolved to %q", cmd.ID) + } + if _, ok := reg.Lookup("does.not.exist"); ok { + t.Fatal("unexpected lookup hit") + } +} + +func TestVariantPlatformsExplicit(t *testing.T) { + for _, cmd := range Builtin().Commands { + for _, variant := range cmd.Variants { + if variant.Match.OS == "" { + t.Errorf("command %q has a variant without an OS constraint: %v", cmd.ID, variant.Argv) + } + } + } +} + +func plainCommand(id string) Command { + return Command{ + ID: id, + Summary: "summary", + Variants: []Variant{{Argv: []string{"x"}}}, + } +} + +func TestValidateErrors(t *testing.T) { + withBlankDistroLike := plainCommand("a") + withBlankDistroLike.Variants = []Variant{{ + Match: Match{DistroLike: []string{" "}}, + Argv: []string{"x"}, + }} + + withEmptyArgv := plainCommand("a") + withEmptyArgv.Variants = []Variant{{}} + + noVariants := plainCommand("a") + noVariants.Variants = nil + + noSummary := plainCommand("a") + noSummary.Summary = "" + + noID := plainCommand("") + noID.ID = "" + + aliased := func(first, second []string) Registry { + a, b := plainCommand("a"), plainCommand("b") + a.Aliases, b.Aliases = first, second + return Registry{Commands: []Command{a, b}} + } + + tests := []struct { + name string + registry Registry + wantErr string + }{ + {"empty ID", Registry{Commands: []Command{noID}}, "empty ID"}, + {"empty summary", Registry{Commands: []Command{noSummary}}, "empty summary"}, + {"duplicate ID", Registry{Commands: []Command{plainCommand("a"), plainCommand("a")}}, "duplicate ID"}, + {"duplicate alias", aliased([]string{"z"}, []string{"z"}), "already used"}, + {"no variants", Registry{Commands: []Command{noVariants}}, "no variants"}, + {"empty argv", Registry{Commands: []Command{withEmptyArgv}}, "empty argv"}, + {"blank DistroLike", Registry{Commands: []Command{withBlankDistroLike}}, "empty DistroLike"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := tc.registry.Validate() + if err == nil { + t.Fatal("expected an error") + } + if !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("error %q does not contain %q", err, tc.wantErr) + } + }) + } +} + +func TestMatchIsComparable(t *testing.T) { + a := Match{OS: "linux", Distro: "centos", DistroLike: []string{"rhel"}, Runtime: "docker"} + b := Match{OS: "linux", Distro: "centos", DistroLike: []string{"rhel"}, Runtime: "docker"} + if !reflect.DeepEqual(a, b) { + t.Fatal("Match should be a comparable plain struct value") + } +} diff --git a/internal/resolve/resolve.go b/internal/resolve/resolve.go new file mode 100644 index 0000000..de20aab --- /dev/null +++ b/internal/resolve/resolve.go @@ -0,0 +1,300 @@ +// Package resolve picks the most specific registry variant for a detected +// platform, and explains why each candidate did or did not match. +package resolve + +import ( + "errors" + "fmt" + "strings" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform" + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry" +) + +// Scoring weights. A variant must match on every declared constraint; the +// score decides among the variants that do match. +const ( + scoreDistroExact = 3 + scoreDistroLike = 2 + scoreRuntime = 2 +) + +// ErrNoMatch is returned when no variant fits the environment. +var ErrNoMatch = errors.New("no matching variant") + +// ErrAmbiguous is returned when two or more variants tie for best match. +var ErrAmbiguous = errors.New("ambiguous match") + +// Candidate is one variant together with the evaluation outcome. +type Candidate struct { + Index int + Variant registry.Variant + Score int + // Matched reports whether every declared constraint was satisfied. + Matched bool + // Reason explains the decision in human-readable form. + Reason string +} + +// Result is a successful resolution. +type Result struct { + Command *registry.Command + Info platform.Info + Variant registry.Variant + VariantIdx int + Score int + Candidates []Candidate +} + +// NoMatchError describes why resolution failed. +type NoMatchError struct { + CommandID string + Info platform.Info + Candidates []Candidate +} + +func (e *NoMatchError) Error() string { + return fmt.Sprintf("命令 %q 不支持当前平台 %s", e.CommandID, DescribeInfo(e.Info)) +} + +// Unwrap lets callers test with errors.Is(err, ErrNoMatch). +func (e *NoMatchError) Unwrap() error { return ErrNoMatch } + +// AmbiguousError reports tied candidates. +type AmbiguousError struct { + CommandID string + Info platform.Info + Candidates []Candidate +} + +func (e *AmbiguousError) Error() string { + return fmt.Sprintf("命令 %q 在当前平台 %s 上匹配到多个同样具体的变体", e.CommandID, DescribeInfo(e.Info)) +} + +// Unwrap lets callers test with errors.Is(err, ErrAmbiguous). +func (e *AmbiguousError) Unwrap() error { return ErrAmbiguous } + +// Resolve evaluates every variant of cmd against info and returns the single +// most specific match. The returned Result always carries all candidates so +// callers can render an explanation. +func Resolve(cmd *registry.Command, info platform.Info) (Result, error) { + result := Result{Command: cmd, Info: info, VariantIdx: -1} + var best []Candidate + + for i, variant := range cmd.Variants { + score, matched, reason := Evaluate(variant.Match, info) + candidate := Candidate{ + Index: i, + Variant: variant, + Score: score, + Matched: matched, + Reason: reason, + } + result.Candidates = append(result.Candidates, candidate) + if !matched { + continue + } + switch { + case len(best) == 0: + best = []Candidate{candidate} + case betterThan(candidate, best[0]): + best = []Candidate{candidate} + case ties(candidate, best[0]): + best = append(best, candidate) + } + } + + switch { + case len(best) == 0: + return result, &NoMatchError{CommandID: cmd.ID, Info: info, Candidates: result.Candidates} + case len(best) > 1: + return result, &AmbiguousError{CommandID: cmd.ID, Info: info, Candidates: result.Candidates} + } + + result.Variant = best[0].Variant + result.VariantIdx = best[0].Index + result.Score = best[0].Score + return result, nil +} + +// ResolveID looks a command up by ID or alias and resolves it. +func ResolveID(reg *registry.Registry, id string, info platform.Info) (Result, error) { + cmd, ok := reg.Lookup(id) + if !ok { + return Result{}, fmt.Errorf("未知命令 %q", id) + } + return Resolve(cmd, info) +} + +// Evaluate checks a single variant against the environment. It returns the +// specificity score, whether all constraints matched, and the reasoning. +func Evaluate(m registry.Match, info platform.Info) (score int, matched bool, reason string) { + var notes []string + rejected := "" + + reject := func(note string) { + if rejected == "" { + rejected = note + } + } + add := func(note string) { notes = append(notes, note) } + + if m.OS != "" { + if m.OS != info.OS { + reject(fmt.Sprintf("os=%s 不匹配(当前 %s)", m.OS, info.OS)) + } else { + add("os=" + m.OS) + } + } + if m.Arch != "" { + if m.Arch != info.Arch { + reject(fmt.Sprintf("arch=%s 不匹配(当前 %s)", m.Arch, info.Arch)) + } else { + add("arch=" + m.Arch) + } + } + if m.Distro != "" { + if m.Distro != info.Distro { + reject(fmt.Sprintf("distro=%s 不匹配(当前 %s)", m.Distro, orUnknown(info.Distro))) + } else { + score += scoreDistroExact + add(fmt.Sprintf("distro=%s (+%d)", m.Distro, scoreDistroExact)) + } + } + if len(m.DistroLike) > 0 { + if !distroLikeMatches(m.DistroLike, info) { + reject(fmt.Sprintf("ID_LIKE 要求 %s(当前 %s)", strings.Join(m.DistroLike, "|"), describeDistro(info))) + } else { + score += scoreDistroLike + add(fmt.Sprintf("ID_LIKE∈{%s} (+%d)", strings.Join(m.DistroLike, ","), scoreDistroLike)) + } + } + if m.Runtime != "" { + if !contains(info.Runtimes, m.Runtime) { + reject(fmt.Sprintf("需要运行时 %s(未在 PATH 找到)", m.Runtime)) + } else { + score += scoreRuntime + add(fmt.Sprintf("runtime=%s (+%d)", m.Runtime, scoreRuntime)) + } + } + + if rejected != "" { + return 0, false, rejected + } + if len(notes) == 0 { + return 0, true, "通用变体(无平台约束, 0 分)" + } + return score, true, "匹配 " + strings.Join(notes, ", ") +} + +// runtimeRank orders runtimes by preference when two variants are otherwise +// equally specific: podman beats docker because it is the rootless default on +// CentOS-family hosts, and a variant with no runtime constraint ranks last. +func runtimeRank(runtime string) int { + switch strings.ToLower(runtime) { + case "podman": + return 0 + case "docker": + return 1 + case "": + return 3 + default: + return 2 + } +} + +// betterThan reports whether a is a strictly better match than b: higher +// score, then the runtime preference, then the more constrained variant. +func betterThan(a, b Candidate) bool { + if a.Score != b.Score { + return a.Score > b.Score + } + if ra, rb := runtimeRank(a.Variant.Match.Runtime), runtimeRank(b.Variant.Match.Runtime); ra != rb { + return ra < rb + } + return specificity(a.Variant.Match) > specificity(b.Variant.Match) +} + +// ties reports whether two candidates are equally good, in which case the +// resolution is ambiguous rather than silently picking one. +func ties(a, b Candidate) bool { + return a.Score == b.Score && + runtimeRank(a.Variant.Match.Runtime) == runtimeRank(b.Variant.Match.Runtime) && + specificity(a.Variant.Match) == specificity(b.Variant.Match) +} + +// specificity counts the constraints a variant declares, so an OS-only +// variant outranks a fully generic one at equal score. +func specificity(m registry.Match) int { + n := 0 + if m.OS != "" { + n++ + } + if m.Arch != "" { + n++ + } + if m.Distro != "" { + n++ + } + if len(m.DistroLike) > 0 { + n++ + } + if m.Runtime != "" { + n++ + } + return n +} + +func distroLikeMatches(want []string, info platform.Info) bool { + current := make([]string, 0, 1+len(info.DistroLike)) + if info.Distro != "" { + current = append(current, info.Distro) + } + current = append(current, info.DistroLike...) + for _, c := range current { + for _, w := range want { + if strings.EqualFold(c, w) { + return true + } + } + } + return false +} + +func contains(values []string, want string) bool { + for _, v := range values { + if strings.EqualFold(v, want) { + return true + } + } + return false +} + +func orUnknown(s string) string { + if s == "" { + return "未知" + } + return s +} + +func describeDistro(info platform.Info) string { + if info.Distro == "" && len(info.DistroLike) == 0 { + return "未知" + } + if len(info.DistroLike) == 0 { + return info.Distro + } + return info.Distro + " (" + strings.Join(info.DistroLike, " ") + ")" +} + +// DescribeInfo renders the environment compactly for error messages. +func DescribeInfo(info platform.Info) string { + s := info.OS + "/" + info.Arch + if info.Distro != "" { + s += ", distro=" + info.Distro + } + if len(info.Runtimes) > 0 { + s += ", runtimes=" + strings.Join(info.Runtimes, ",") + } + return s +} diff --git a/internal/resolve/resolve_test.go b/internal/resolve/resolve_test.go new file mode 100644 index 0000000..86c34ec --- /dev/null +++ b/internal/resolve/resolve_test.go @@ -0,0 +1,294 @@ +package resolve + +import ( + "errors" + "reflect" + "strings" + "testing" + + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/platform" + "gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry" +) + +func env(osName, arch, distro string, like []string, runtimes ...string) platform.Info { + return platform.Info{ + OS: osName, + Arch: arch, + Distro: distro, + DistroLike: like, + Runtimes: runtimes, + } +} + +func builtin(t *testing.T, id string) *registry.Command { + t.Helper() + cmd, ok := registry.Builtin().Lookup(id) + if !ok { + t.Fatalf("builtin command %q missing", id) + } + return cmd +} + +func TestResolveBuiltin(t *testing.T) { + tests := []struct { + name string + id string + info platform.Info + wantArgv []string + wantScore int + wantIdx int + }{ + { + name: "centos with podman and docker prefers the distro-specific podman variant", + id: "docker.clearsafe", + info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker", "podman"), + wantArgv: []string{"podman", "system", "prune", "-f"}, + wantScore: 5, + wantIdx: 0, + }, + { + name: "centos with docker only", + id: "docker.clearsafe", + info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker"), + wantArgv: []string{"docker", "system", "prune", "-f"}, + wantScore: 5, + wantIdx: 1, + }, + { + name: "generic linux with podman and docker prefers podman", + id: "docker.clearsafe", + info: env("linux", "amd64", "ubuntu", []string{"debian"}, "docker", "podman"), + wantArgv: []string{"podman", "system", "prune", "-f"}, + wantScore: 2, + wantIdx: 2, + }, + { + name: "rocky inherits the generic linux docker variant", + id: "docker.clearsafe", + info: env("linux", "amd64", "rocky", []string{"rhel", "centos"}, "docker"), + wantArgv: []string{"docker", "system", "prune", "-f"}, + wantScore: 2, + wantIdx: 3, + }, + { + name: "darwin docker", + id: "docker.clearsafe", + info: env("darwin", "arm64", "", nil, "docker"), + wantArgv: []string{"docker", "system", "prune", "-f"}, + wantScore: 2, + wantIdx: 4, + }, + { + name: "windows docker", + id: "docker.clearsafe", + info: env("windows", "amd64", "", nil, "docker"), + wantArgv: []string{"docker", "system", "prune", "-f"}, + wantScore: 2, + wantIdx: 5, + }, + { + name: "centos refreshes with dnf exactly", + id: "pkg.refreshindex", + info: env("linux", "amd64", "centos", []string{"rhel", "fedora"}), + wantArgv: []string{"dnf", "makecache"}, + wantScore: 3, + wantIdx: 0, + }, + { + name: "rocky inherits rhel-like dnf", + id: "pkg.refreshindex", + info: env("linux", "amd64", "rocky", []string{"rhel"}), + wantArgv: []string{"dnf", "makecache"}, + wantScore: 2, + wantIdx: 1, + }, + { + name: "ubuntu resolves through ID_LIKE debian", + id: "pkg.refreshindex", + info: env("linux", "amd64", "ubuntu", []string{"debian"}), + wantArgv: []string{"apt-get", "update"}, + wantScore: 2, + wantIdx: 2, + }, + { + name: "alpine has its own distro variant", + id: "pkg.refreshindex", + info: env("linux", "amd64", "alpine", nil), + wantArgv: []string{"apk", "update"}, + wantScore: 3, + wantIdx: 3, + }, + { + name: "darwin homebrew", + id: "pkg.refreshindex", + info: env("darwin", "arm64", "", nil), + wantArgv: []string{"brew", "update"}, + wantIdx: 4, + }, + { + name: "windows winget", + id: "pkg.refreshindex", + info: env("windows", "amd64", "", nil), + wantArgv: []string{"winget", "source", "update"}, + wantIdx: 5, + }, + { + name: "linux listens with ss", + id: "net.ports", + info: env("linux", "amd64", "centos", []string{"rhel"}), + wantArgv: []string{"ss", "-tulnp"}, + wantIdx: 0, + }, + { + name: "darwin listens with lsof", + id: "net.ports", + info: env("darwin", "amd64", "", nil), + wantArgv: []string{"lsof", "-iTCP", "-sTCP:LISTEN", "-nP"}, + wantIdx: 1, + }, + { + name: "windows listens with netstat", + id: "net.ports", + info: env("windows", "amd64", "", nil), + wantArgv: []string{"netstat", "-ano"}, + wantIdx: 2, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Resolve(builtin(t, tc.id), tc.info) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + if !reflect.DeepEqual(got.Variant.Argv, tc.wantArgv) { + t.Errorf("argv = %v, want %v", got.Variant.Argv, tc.wantArgv) + } + if got.VariantIdx != tc.wantIdx { + t.Errorf("variant index = %d, want %d", got.VariantIdx, tc.wantIdx) + } + if got.Score != tc.wantScore { + t.Errorf("score = %d, want %d", got.Score, tc.wantScore) + } + if len(got.Candidates) != len(got.Command.Variants) { + t.Errorf("candidates = %d, want %d", len(got.Candidates), len(got.Command.Variants)) + } + }) + } +} + +func TestResolveNoMatch(t *testing.T) { + info := env("linux", "amd64", "centos", []string{"rhel"}) // no container runtime on PATH + cmd := builtin(t, "docker.clearsafe") + + _, err := Resolve(cmd, info) + if err == nil { + t.Fatal("expected an error without a container runtime") + } + if !errors.Is(err, ErrNoMatch) { + t.Fatalf("error = %v, want ErrNoMatch", err) + } + + var noMatch *NoMatchError + if !errors.As(err, &noMatch) { + t.Fatalf("error type = %T, want *NoMatchError", err) + } + if len(noMatch.Candidates) != len(cmd.Variants) { + t.Errorf("candidates = %d, want %d", len(noMatch.Candidates), len(cmd.Variants)) + } + for _, c := range noMatch.Candidates { + if c.Matched { + t.Errorf("candidate %v should not match", c.Variant.Argv) + } + if c.Reason == "" { + t.Errorf("candidate %v has no reason", c.Variant.Argv) + } + } + if !strings.Contains(err.Error(), "runtime") && !strings.Contains(err.Error(), "不支持") { + t.Errorf("error message lacks detail: %v", err) + } +} + +func TestResolveAmbiguous(t *testing.T) { + cmd := ®istry.Command{ + ID: "demo.ambiguous", + Summary: "two equally generic variants", + Variants: []registry.Variant{ + {Argv: []string{"one"}}, + {Argv: []string{"two"}}, + }, + } + _, err := Resolve(cmd, env("linux", "amd64", "", nil)) + if err == nil { + t.Fatal("expected an ambiguity error") + } + if !errors.Is(err, ErrAmbiguous) { + t.Fatalf("error = %v, want ErrAmbiguous", err) + } + var ambiguous *AmbiguousError + if !errors.As(err, &ambiguous) { + t.Fatalf("error type = %T, want *AmbiguousError", err) + } +} + +func TestResolveArchMismatch(t *testing.T) { + cmd := ®istry.Command{ + ID: "demo.arch", + Summary: "arm64 only", + Variants: []registry.Variant{ + {Match: registry.Match{OS: "linux", Arch: "arm64"}, Argv: []string{"arm"}}, + }, + } + _, err := Resolve(cmd, env("linux", "amd64", "", nil)) + if !errors.Is(err, ErrNoMatch) { + t.Fatalf("error = %v, want ErrNoMatch", err) + } +} + +func TestResolveSpecificBeatsGeneric(t *testing.T) { + cmd := ®istry.Command{ + ID: "demo.specific", + Summary: "specific and generic", + Variants: []registry.Variant{ + {Argv: []string{"generic"}}, + {Match: registry.Match{OS: "linux"}, Argv: []string{"linux"}}, + }, + } + got, err := Resolve(cmd, env("linux", "amd64", "centos", []string{"rhel"})) + if err != nil { + t.Fatalf("Resolve() error: %v", err) + } + // Both score 0 here, so the runtime tier and then declaration order decide; + // the important property is that a single variant wins. + if len(got.Variant.Argv) == 0 { + t.Fatal("no variant chosen") + } +} + +func TestEvaluateRejects(t *testing.T) { + info := env("linux", "amd64", "centos", []string{"rhel", "fedora"}, "docker") + + cases := []struct { + name string + match registry.Match + want string + }{ + {"os", registry.Match{OS: "darwin"}, "os=darwin"}, + {"arch", registry.Match{Arch: "arm64"}, "arch=arm64"}, + {"distro", registry.Match{Distro: "debian"}, "distro=debian"}, + {"distro like", registry.Match{DistroLike: []string{"debian"}}, "ID_LIKE"}, + {"runtime", registry.Match{Runtime: "podman"}, "podman"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + _, matched, reason := Evaluate(tc.match, info) + if matched { + t.Fatalf("match should fail: %+v", tc.match) + } + if !strings.Contains(reason, tc.want) { + t.Fatalf("reason %q does not contain %q", reason, tc.want) + } + }) + } +} diff --git a/scripts/build/main.go b/scripts/build/main.go new file mode 100644 index 0000000..fa0a864 --- /dev/null +++ b/scripts/build/main.go @@ -0,0 +1,194 @@ +// Command build cross-compiles kopsc for the supported platforms. +// +// Usage: +// +// go run ./scripts/build # every supported target +// go run ./scripts/build -host # current OS/arch only +// go run ./scripts/build -targets linux/amd64,windows/arm64 +// go run ./scripts/build -version 1.2.3 -out bin +// +// Output names are kopsc---[.exe] under -out (default: dist). +package main + +import ( + "errors" + "flag" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" +) + +type target struct { + os string + arch string +} + +// defaultTargets is the release matrix. +var defaultTargets = []target{ + {"linux", "amd64"}, + {"linux", "arm64"}, + {"darwin", "amd64"}, + {"darwin", "arm64"}, + {"windows", "amd64"}, + {"windows", "arm64"}, +} + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "build: "+err.Error()) + os.Exit(1) + } +} + +func run(args []string) error { + fs := flag.NewFlagSet("build", flag.ContinueOnError) + fs.SetOutput(os.Stderr) + + var ( + outDir = fs.String("out", "dist", "output directory") + versionArg = fs.String("version", "", "version string (default: git describe --tags --always --dirty)") + targetArg = fs.String("targets", "", "comma-separated os/arch list, e.g. linux/amd64,windows/arm64") + hostOnly = fs.Bool("host", false, "build only the current os/arch") + noStrip = fs.Bool("no-strip", false, "keep symbols (do not pass -s -w)") + quiet = fs.Bool("quiet", false, "only print errors") + ) + if err := fs.Parse(args); err != nil { + return err + } + if fs.NArg() > 0 { + return fmt.Errorf("unexpected arguments: %s", strings.Join(fs.Args(), " ")) + } + + var ( + targets []target + err error + ) + switch { + case *hostOnly && *targetArg != "": + return errors.New("-host and -targets cannot be used together") + case *hostOnly: + targets = []target{{runtime.GOOS, runtime.GOARCH}} + default: + targets, err = parseTargets(*targetArg) + if err != nil { + return err + } + } + + version := *versionArg + if version == "" { + version = detectVersion() + } + + if err := os.MkdirAll(*outDir, 0o755); err != nil { + return fmt.Errorf("create output directory: %w", err) + } + + logf := func(format string, a ...any) { + if !*quiet { + fmt.Printf(format, a...) + } + } + logf("kopsc %s -> %s/\n", version, filepath.ToSlash(*outDir)) + + for _, t := range targets { + path, size, err := build(t, version, *outDir, !*noStrip) + if err != nil { + return fmt.Errorf("%s/%s: %w", t.os, t.arch, err) + } + logf(" %-8s %-6s %-42s %6.1f MiB\n", t.os, t.arch, path, float64(size)/(1<<20)) + } + + logf("done: %d target(s)\n", len(targets)) + return nil +} + +// parseTargets parses "os/arch,..." and falls back to the release matrix. +func parseTargets(spec string) ([]target, error) { + if strings.TrimSpace(spec) == "" { + return defaultTargets, nil + } + + var targets []target + seen := make(map[target]bool) + for _, item := range strings.Split(spec, ",") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + osName, arch, ok := strings.Cut(item, "/") + osName, arch = strings.TrimSpace(osName), strings.TrimSpace(arch) + if !ok || osName == "" || arch == "" { + return nil, fmt.Errorf("invalid target %q, want os/arch such as linux/amd64", item) + } + t := target{osName, arch} + if seen[t] { + continue + } + seen[t] = true + targets = append(targets, t) + } + if len(targets) == 0 { + return nil, errors.New("no targets to build") + } + return targets, nil +} + +// artifactName returns the file name for a target. +func artifactName(version string, t target) string { + name := fmt.Sprintf("kopsc-%s-%s-%s", version, t.os, t.arch) + if t.os == "windows" { + name += ".exe" + } + return name +} + +// build compiles one target and reports the artifact path and size. +func build(t target, version, outDir string, strip bool) (string, int64, error) { + path := filepath.Join(outDir, artifactName(version, t)) + + ldflags := "-X main.version=" + version + if strip { + ldflags = "-s -w " + ldflags + } + + cmd := exec.Command("go", "build", + "-trimpath", + "-ldflags", ldflags, + "-o", path, + "./cmd/kopsc", + ) + cmd.Env = append(os.Environ(), + "CGO_ENABLED=0", + "GOOS="+t.os, + "GOARCH="+t.arch, + ) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return "", 0, fmt.Errorf("go build failed: %w", err) + } + + info, err := os.Stat(path) + if err != nil { + return "", 0, fmt.Errorf("stat artifact: %w", err) + } + return path, info.Size(), nil +} + +// detectVersion asks git for a version, falling back to "dev". +func detectVersion() string { + out, err := exec.Command("git", "describe", "--tags", "--always", "--dirty").Output() + if err != nil { + return "dev" + } + version := strings.TrimSpace(string(out)) + if version == "" { + return "dev" + } + return version +} diff --git a/scripts/build/main_test.go b/scripts/build/main_test.go new file mode 100644 index 0000000..79b93c2 --- /dev/null +++ b/scripts/build/main_test.go @@ -0,0 +1,60 @@ +package main + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseTargetsDefault(t *testing.T) { + got, err := parseTargets("") + if err != nil { + t.Fatalf("parseTargets(\"\") error: %v", err) + } + if !reflect.DeepEqual(got, defaultTargets) { + t.Fatalf("got %v, want default matrix %v", got, defaultTargets) + } +} + +func TestParseTargetsCustom(t *testing.T) { + got, err := parseTargets(" linux/amd64 , windows/arm64 ,linux/amd64,") + if err != nil { + t.Fatalf("parseTargets error: %v", err) + } + want := []target{{"linux", "amd64"}, {"windows", "arm64"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestParseTargetsInvalid(t *testing.T) { + for _, spec := range []string{"linux", "/amd64", "linux/", " , "} { + if _, err := parseTargets(spec); err == nil { + t.Errorf("parseTargets(%q) should fail", spec) + } + } +} + +func TestArtifactName(t *testing.T) { + cases := []struct { + version string + t target + want string + }{ + {"1.2.3", target{"linux", "amd64"}, "kopsc-1.2.3-linux-amd64"}, + {"v1.2.3-2-gabc-dirty", target{"darwin", "arm64"}, "kopsc-v1.2.3-2-gabc-dirty-darwin-arm64"}, + {"dev", target{"windows", "amd64"}, "kopsc-dev-windows-amd64.exe"}, + } + for _, tc := range cases { + if got := artifactName(tc.version, tc.t); got != tc.want { + t.Errorf("artifactName(%q, %v) = %q, want %q", tc.version, tc.t, got, tc.want) + } + } +} + +func TestRunRejectsConflictingFlags(t *testing.T) { + err := run([]string{"-host", "-targets", "linux/amd64"}) + if err == nil || !strings.Contains(err.Error(), "cannot be used together") { + t.Fatalf("err = %v, want conflict error", err) + } +}