feat: 跨平台命令助手 kopsc 初始实现
- 意图命令按 OS/架构、Linux 发行版与容器运行时解析并执行对应底层命令 - 内置 docker.clearsafe、net.ports、pkg.refreshindex - --dry-run 预览、list/explain/platform/version 辅助命令 - 纯 Go 跨平台构建脚本 scripts/build
This commit is contained in:
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# Build output
|
||||||
|
/bin/
|
||||||
|
/dist/
|
||||||
|
|
||||||
|
# Go test / coverage artifacts
|
||||||
|
*.test
|
||||||
|
*.out
|
||||||
|
coverage.*
|
||||||
|
|
||||||
|
# Editor / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
@@ -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)
|
||||||
|
```
|
||||||
@@ -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:]))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user