feat: 跨平台命令助手 kopsc 初始实现

- 意图命令按 OS/架构、Linux 发行版与容器运行时解析并执行对应底层命令
- 内置 docker.clearsafe、net.ports、pkg.refreshindex
- --dry-run 预览、list/explain/platform/version 辅助命令
- 纯 Go 跨平台构建脚本 scripts/build
This commit is contained in:
2026-10-31 13:01:39 +08:00
commit 7d9852958f
18 changed files with 2650 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
# Build output
/bin/
/dist/
# Go test / coverage artifacts
*.test
*.out
coverage.*
# Editor / OS
.idea/
.vscode/
.DS_Store
Thumbs.db
+151
View File
@@ -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-<version>-<os>-<arch>[.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)
```
+18
View File
@@ -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:]))
}
+9
View File
@@ -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
)
+10
View File
@@ -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=
+276
View File
@@ -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
},
}
}
+268
View File
@@ -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 = &registry.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)
}
}
+187
View File
@@ -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
}
+221
View File
@@ -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: &registry.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)
}
}
+118
View File
@@ -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()
}
+160
View File
@@ -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)
}
}
+109
View File
@@ -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",
},
},
},
}}
}
+120
View File
@@ -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
}
+141
View File
@@ -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")
}
}
+300
View File
@@ -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
}
+294
View File
@@ -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 := &registry.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 := &registry.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 := &registry.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)
}
})
}
}
+194
View File
@@ -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-<version>-<os>-<arch>[.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
}
+60
View File
@@ -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)
}
}