feat: 新增 disk usage 内置命令(跨平台 du 等价)
- 注册表新增 Variant.Builtin 内置动作,与 Argv 互斥并参与平台匹配 - disk.usage 支持位置参数与 --path/--path=,输出按大小降序并带合计 - Unix 用设备号实现 -x 不跨文件系统,Windows 降级为不跟随 reparse point - list/help/explain 支持展示内置动作,补充单元与 CLI 测试、README
This commit is contained in:
@@ -54,12 +54,37 @@ kopsc docker clearsafe --dry-run # 只打印将执行的命令与判定依据
|
|||||||
kopsc docker clearsafe -h # 该意图的说明、当前平台命中结果与选项
|
kopsc docker clearsafe -h # 该意图的说明、当前平台命中结果与选项
|
||||||
kopsc docker clearsafe -- --filter until=24h # -- 之后的参数原样透传给底层命令
|
kopsc docker clearsafe -- --filter until=24h # -- 之后的参数原样透传给底层命令
|
||||||
|
|
||||||
|
kopsc disk usage /var # 磁盘占用排行(du -x --max-depth=1 -h | sort -hr 的跨平台版)
|
||||||
|
kopsc disk usage --path /var # 同上,用命名参数指定路径
|
||||||
|
kopsc disk usage /var --dry-run # 预览(内置命令同样支持)
|
||||||
|
|
||||||
kopsc list # 全部命令及当前平台可用性
|
kopsc list # 全部命令及当前平台可用性
|
||||||
kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因
|
kopsc explain docker.clearsafe # 匹配过程、全部候选及各自被选中/拒绝的原因
|
||||||
kopsc platform # 探测到的 OS/ARCH/发行版/运行时
|
kopsc platform # 探测到的 OS/ARCH/发行版/运行时
|
||||||
kopsc version # 版本与平台
|
kopsc version # 版本与平台
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 磁盘占用排行(disk.usage)
|
||||||
|
|
||||||
|
`kopsc disk usage <路径>` 等价于 `du -x --max-depth=1 -h <路径> | sort -hr`,但由 kopsc
|
||||||
|
内置实现,因此 Linux、macOS、Windows 输出完全一致,不依赖 `du`、`sort` 或 PowerShell:
|
||||||
|
|
||||||
|
```text
|
||||||
|
$ kopsc disk usage /var
|
||||||
|
1.2G /var/lib
|
||||||
|
800.0M /var/cache
|
||||||
|
4.0K /var/tmp
|
||||||
|
0 /var/opt
|
||||||
|
总计 2.0G /var
|
||||||
|
```
|
||||||
|
|
||||||
|
- 路径**必填**:位置参数(`/var`)或 `--path /var`、`--path=/var` 均可,一次一个。
|
||||||
|
- 统计每个直接子项:普通文件按自身大小,目录递归累计其下普通文件的大小。
|
||||||
|
- 不跟随符号链接;Unix 上通过设备号实现 `-x`(不跨文件系统),Windows 上不跟随
|
||||||
|
reparse point,目录遍历本身不会跨越卷。
|
||||||
|
- 输出按大小降序,大小采用与 `du -h` 相同的二进制单位(1.0K、1.5M、2.0G)。
|
||||||
|
- 末尾输出该路径的合计行。
|
||||||
|
|
||||||
### 选项解析规则
|
### 选项解析规则
|
||||||
|
|
||||||
叶子命令禁用了 cobra 的 flag 解析,自行处理:
|
叶子命令禁用了 cobra 的 flag 解析,自行处理:
|
||||||
@@ -79,6 +104,7 @@ kopsc version # 版本与平台
|
|||||||
| 命令 | 说明 | 平台变体 |
|
| 命令 | 说明 | 平台变体 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `docker.clearsafe` | 安全清理容器/网络/构建缓存 | Linux(centos)+podman、Linux(centos)+docker、Linux+podman、Linux+docker、darwin+docker、windows+docker,均为 `system prune -f` |
|
| `docker.clearsafe` | 安全清理容器/网络/构建缓存 | Linux(centos)+podman、Linux(centos)+docker、Linux+podman、Linux+docker、darwin+docker、windows+docker,均为 `system prune -f` |
|
||||||
|
| `disk.usage` | 目录占用排行(等价 `du -x --max-depth=1 -h <路径> \| sort -hr`) | Linux/macOS/Windows 均使用 kopsc 内置实现,保证输出一致 |
|
||||||
| `net.ports` | 列出监听中的端口 | Linux `ss -tulnp`、macOS `lsof -iTCP -sTCP:LISTEN -nP`、Windows `netstat -ano` |
|
| `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` |
|
| `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` |
|
||||||
|
|
||||||
@@ -127,6 +153,9 @@ kopsc version # 版本与平台
|
|||||||
```
|
```
|
||||||
|
|
||||||
- `Argv[0]` 是程序,其余是参数;需要 shell 语义时设 `Shell: true`(Linux/macOS 用 `sh -c`,Windows 用 `cmd /c`)。
|
- `Argv[0]` 是程序,其余是参数;需要 shell 语义时设 `Shell: true`(Linux/macOS 用 `sh -c`,Windows 用 `cmd /c`)。
|
||||||
|
- 当平台没有可移植的系统命令时,用 `Builtin` 代替 `Argv`(二者互斥):
|
||||||
|
`Builtin: "disk.usage"` 指向 [`internal/executor`](internal/executor) 中注册的实现,
|
||||||
|
这样三平台共用同一份逻辑。新增内置动作需在 `executor.builtins` 中注册。
|
||||||
- 新增命令时给 `Command` 填 `ID`(点号分隔,决定 CLI 层级)、`Summary`、`Action`,别名不得重复。
|
- 新增命令时给 `Command` 填 `ID`(点号分隔,决定 CLI 层级)、`Summary`、`Action`,别名不得重复。
|
||||||
- 提交前跑 `go test ./...`:`registry.Validate()` 与快照测试会拦截重复 ID/别名、空变体、空 argv。
|
- 提交前跑 `go test ./...`:`registry.Validate()` 与快照测试会拦截重复 ID/别名、空变体、空 argv。
|
||||||
|
|
||||||
|
|||||||
+12
-4
@@ -111,6 +111,14 @@ func findChild(parent *cobra.Command, name string) *cobra.Command {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// variantDisplay renders how a variant is executed, for list/help/explain.
|
||||||
|
func variantDisplay(v registry.Variant) string {
|
||||||
|
if v.Builtin != "" {
|
||||||
|
return "(内置) " + v.Builtin
|
||||||
|
}
|
||||||
|
return strings.Join(v.Argv, " ")
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command {
|
func (a *App) newLeaf(cmd *registry.Command, name string) *cobra.Command {
|
||||||
leaf := &cobra.Command{
|
leaf := &cobra.Command{
|
||||||
Use: name + " [--dry-run] [-h] [-- 透传参数...]",
|
Use: name + " [--dry-run] [-h] [-- 透传参数...]",
|
||||||
@@ -174,7 +182,7 @@ func (a *App) printLeafHelp(w io.Writer, cmd *registry.Command) {
|
|||||||
fmt.Fprintf(w, "当前平台: %s\n", resolve.DescribeInfo(a.Info))
|
fmt.Fprintf(w, "当前平台: %s\n", resolve.DescribeInfo(a.Info))
|
||||||
|
|
||||||
if result, err := resolve.Resolve(cmd, a.Info); err == nil {
|
if result, err := resolve.Resolve(cmd, a.Info); err == nil {
|
||||||
fmt.Fprintf(w, "将执行: %s\n\n", strings.Join(result.Variant.Argv, " "))
|
fmt.Fprintf(w, "将执行: %s\n\n", variantDisplay(result.Variant))
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(w, "当前平台不可用: %v\n\n", err)
|
fmt.Fprintf(w, "当前平台不可用: %v\n\n", err)
|
||||||
}
|
}
|
||||||
@@ -197,7 +205,7 @@ func (a *App) newListCmd() *cobra.Command {
|
|||||||
entry := &a.Registry.Commands[i]
|
entry := &a.Registry.Commands[i]
|
||||||
status, planned := "可用", "-"
|
status, planned := "可用", "-"
|
||||||
if result, err := resolve.Resolve(entry, a.Info); err == nil {
|
if result, err := resolve.Resolve(entry, a.Info); err == nil {
|
||||||
planned = strings.Join(result.Variant.Argv, " ")
|
planned = variantDisplay(result.Variant)
|
||||||
} else {
|
} else {
|
||||||
status = "不可用"
|
status = "不可用"
|
||||||
}
|
}
|
||||||
@@ -230,7 +238,7 @@ func (a *App) newExplainCmd() *cobra.Command {
|
|||||||
fmt.Fprintf(w, "结果: 当前平台不可用 — %v\n\n", err)
|
fmt.Fprintf(w, "结果: 当前平台不可用 — %v\n\n", err)
|
||||||
} else {
|
} else {
|
||||||
fmt.Fprintf(w, "结果: 命中变体 #%d(%d 分)→ %s\n\n",
|
fmt.Fprintf(w, "结果: 命中变体 #%d(%d 分)→ %s\n\n",
|
||||||
result.VariantIdx, result.Score, strings.Join(result.Variant.Argv, " "))
|
result.VariantIdx, result.Score, variantDisplay(result.Variant))
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintln(w, "候选:")
|
fmt.Fprintln(w, "候选:")
|
||||||
@@ -244,7 +252,7 @@ func (a *App) newExplainCmd() *cobra.Command {
|
|||||||
note = " # " + c.Variant.Note
|
note = " # " + c.Variant.Note
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " %s #%d %d 分 %s -> %s%s\n",
|
fmt.Fprintf(w, " %s #%d %d 分 %s -> %s%s\n",
|
||||||
mark, c.Index, c.Score, c.Reason, strings.Join(c.Variant.Argv, " "), note)
|
mark, c.Index, c.Score, c.Reason, variantDisplay(c.Variant), note)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -241,6 +242,80 @@ func TestParseLeafArgs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageCommandThroughCLI(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "a.bin"), bytes.Repeat([]byte("x"), 4096), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "sub"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "sub", "b.bin"), bytes.Repeat([]byte("x"), 1024), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
app, stdout, stderr := testApp(centos("docker"))
|
||||||
|
if code := app.Execute([]string{"disk", "usage", root}); code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
|
||||||
|
}
|
||||||
|
out := stdout.String()
|
||||||
|
for _, want := range []string{"4.0K", "1.0K", "a.bin", "sub", "总计 " + root} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("disk usage output missing %q:\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageAcceptsPathFlagThroughCLI(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
|
||||||
|
app, stdout, stderr := testApp(centos("docker"))
|
||||||
|
if code := app.Execute([]string{"disk", "usage", "--path", root}); code != 0 {
|
||||||
|
t.Fatalf("exit code = %d, stderr=%s", code, stderr.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "总计 "+root) {
|
||||||
|
t.Errorf("unexpected output:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageRequiresPathThroughCLI(t *testing.T) {
|
||||||
|
app, _, stderr := testApp(centos("docker"))
|
||||||
|
|
||||||
|
if code := app.Execute([]string{"disk", "usage"}); code != 1 {
|
||||||
|
t.Fatalf("exit code = %d, want 1", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stderr.String(), "必须提供路径") {
|
||||||
|
t.Errorf("stderr = %s", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageDryRunDoesNotTouchPath(t *testing.T) {
|
||||||
|
app, stdout, _ := testApp(centos("docker"))
|
||||||
|
|
||||||
|
code := app.Execute([]string{"disk", "usage", "/path/that/does/not/exist", "--dry-run"})
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("exit code = %d", code)
|
||||||
|
}
|
||||||
|
out := stdout.String()
|
||||||
|
if !strings.Contains(out, "(内置) disk.usage /path/that/does/not/exist") {
|
||||||
|
t.Errorf("dry-run output missing builtin plan:\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "无法访问") {
|
||||||
|
t.Errorf("dry-run should not execute the builtin:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageHelp(t *testing.T) {
|
||||||
|
app, stdout, _ := testApp(centos("docker"))
|
||||||
|
|
||||||
|
if code := app.Execute([]string{"disk", "usage", "-h"}); code != 0 {
|
||||||
|
t.Fatalf("exit code = %d", code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(stdout.String(), "(内置) disk.usage") {
|
||||||
|
t.Errorf("help output missing builtin target:\n%s", stdout.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestCLIHelperProcess is re-executed as a child to verify exit-code passthrough.
|
// TestCLIHelperProcess is re-executed as a child to verify exit-code passthrough.
|
||||||
func TestCLIHelperProcess(t *testing.T) {
|
func TestCLIHelperProcess(t *testing.T) {
|
||||||
if os.Getenv("KOPSC_CLI_HELPER") != "1" {
|
if os.Getenv("KOPSC_CLI_HELPER") != "1" {
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuiltinRequest carries the caller's arguments and output streams to an
|
||||||
|
// in-process command implementation.
|
||||||
|
type BuiltinRequest struct {
|
||||||
|
Args []string
|
||||||
|
Stdout io.Writer
|
||||||
|
Stderr io.Writer
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builtin executes a command inside kopsc instead of spawning a child. It is
|
||||||
|
// used where no portable system utility exists (for example du on Windows).
|
||||||
|
type Builtin func(ctx context.Context, req BuiltinRequest) error
|
||||||
|
|
||||||
|
var builtins = map[string]Builtin{
|
||||||
|
"disk.usage": runDiskUsage,
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupBuiltin returns the implementation registered under name.
|
||||||
|
func LookupBuiltin(name string) (Builtin, bool) {
|
||||||
|
fn, ok := builtins[name]
|
||||||
|
return fn, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuiltinNames lists the registered builtin implementations.
|
||||||
|
func BuiltinNames() []string {
|
||||||
|
names := make([]string, 0, len(builtins))
|
||||||
|
for name := range builtins {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deviceOf returns the device ID backing a file, used to implement du's -x
|
||||||
|
// (do not cross filesystems).
|
||||||
|
func deviceOf(info os.FileInfo) (uint64, bool) {
|
||||||
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
||||||
|
if !ok {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return uint64(stat.Dev), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func deviceOfPath(path string) (uint64, bool) {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return deviceOf(info)
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package executor
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// Windows has no stable per-file device ID in os.FileInfo. Directory walks do
|
||||||
|
// not cross volumes on their own, so -x degrades to "do not follow reparse
|
||||||
|
// points", which filepath.WalkDir already guarantees.
|
||||||
|
func deviceOf(os.FileInfo) (uint64, bool) { return 0, false }
|
||||||
|
|
||||||
|
func deviceOfPath(string) (uint64, bool) { return 0, false }
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runDiskUsage implements `du -x --max-depth=1 -h <path> | sort -hr`.
|
||||||
|
//
|
||||||
|
// It reports every direct child of the given directory: regular files by their
|
||||||
|
// size, directories by the recursive size of their regular files. Symbolic
|
||||||
|
// links are not followed and, where the platform exposes device IDs, nested
|
||||||
|
// filesystems are not crossed (-x).
|
||||||
|
func runDiskUsage(ctx context.Context, req BuiltinRequest) error {
|
||||||
|
paths, err := parseDiskUsageArgs(req.Args)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
root := paths[0]
|
||||||
|
|
||||||
|
rootInfo, err := os.Stat(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("无法访问 %q: %w", root, err)
|
||||||
|
}
|
||||||
|
if !rootInfo.IsDir() {
|
||||||
|
return fmt.Errorf("%q 不是目录", root)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(root)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("读取目录 %q 失败: %w", root, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rootDev, checkDev := deviceOf(rootInfo)
|
||||||
|
|
||||||
|
type entrySize struct {
|
||||||
|
path string
|
||||||
|
size int64
|
||||||
|
}
|
||||||
|
items := make([]entrySize, 0, len(entries))
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
path := filepath.Join(root, entry.Name())
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
// Unreadable entries are skipped rather than failing the whole run.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var size int64
|
||||||
|
switch {
|
||||||
|
case info.IsDir():
|
||||||
|
if checkDev {
|
||||||
|
if dev, ok := deviceOf(info); ok && dev != rootDev {
|
||||||
|
// -x: a different filesystem contributes nothing.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
size, err = dirSize(ctx, path, rootDev, checkDev)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case info.Mode().IsRegular():
|
||||||
|
size = info.Size()
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, entrySize{path: path, size: size})
|
||||||
|
total += size
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.SliceStable(items, func(i, j int) bool {
|
||||||
|
if items[i].size != items[j].size {
|
||||||
|
return items[i].size > items[j].size
|
||||||
|
}
|
||||||
|
return items[i].path < items[j].path
|
||||||
|
})
|
||||||
|
|
||||||
|
w := req.Stdout
|
||||||
|
for _, item := range items {
|
||||||
|
fmt.Fprintf(w, "%-8s %s\n", humanSize(item.size), item.path)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%-8s 总计 %s\n", humanSize(total), root)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDiskUsageArgs accepts either a positional path or --path/--path=value.
|
||||||
|
// Exactly one path is required.
|
||||||
|
func parseDiskUsageArgs(args []string) ([]string, error) {
|
||||||
|
var paths []string
|
||||||
|
positionalOnly := false
|
||||||
|
|
||||||
|
for i := 0; i < len(args); i++ {
|
||||||
|
arg := args[i]
|
||||||
|
switch {
|
||||||
|
case positionalOnly:
|
||||||
|
paths = append(paths, arg)
|
||||||
|
case arg == "--":
|
||||||
|
positionalOnly = true
|
||||||
|
case arg == "--path":
|
||||||
|
if i+1 >= len(args) {
|
||||||
|
return nil, errors.New("--path 缺少参数,例如 --path /var")
|
||||||
|
}
|
||||||
|
paths = append(paths, args[i+1])
|
||||||
|
i++
|
||||||
|
case strings.HasPrefix(arg, "--path="):
|
||||||
|
value := strings.TrimPrefix(arg, "--path=")
|
||||||
|
if value == "" {
|
||||||
|
return nil, errors.New("--path 缺少参数,例如 --path /var")
|
||||||
|
}
|
||||||
|
paths = append(paths, value)
|
||||||
|
case strings.HasPrefix(arg, "-") && arg != "-":
|
||||||
|
return nil, fmt.Errorf("未知选项 %q:深度(-d 1)、人类可读(-h)与不跨文件系统(-x)已固定", arg)
|
||||||
|
default:
|
||||||
|
paths = append(paths, arg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return nil, errors.New("必须提供路径,例如 kopsc disk usage /var 或 kopsc disk usage --path /var")
|
||||||
|
}
|
||||||
|
if len(paths) > 1 {
|
||||||
|
return nil, fmt.Errorf("一次只支持一个路径,收到 %d 个", len(paths))
|
||||||
|
}
|
||||||
|
return paths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dirSize sums the sizes of regular files under dir without following symbolic
|
||||||
|
// links or crossing into another filesystem.
|
||||||
|
func dirSize(ctx context.Context, dir string, rootDev uint64, checkDev bool) (int64, error) {
|
||||||
|
var total int64
|
||||||
|
|
||||||
|
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
// Permission problems on one subtree must not abort the report.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d.IsDir() {
|
||||||
|
if path != dir && checkDev {
|
||||||
|
if dev, ok := deviceOfPath(path); ok && dev != rootDev {
|
||||||
|
return fs.SkipDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.Mode().IsRegular() {
|
||||||
|
total += info.Size()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return total, err
|
||||||
|
}
|
||||||
|
return total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// humanSize formats bytes the way `du -h` does: bytes below 1 KiB are printed
|
||||||
|
// bare, larger values use binary units with one decimal.
|
||||||
|
func humanSize(n int64) string {
|
||||||
|
if n < 1024 {
|
||||||
|
return strconv.FormatInt(n, 10)
|
||||||
|
}
|
||||||
|
units := []string{"K", "M", "G", "T", "P", "E"}
|
||||||
|
value := float64(n)
|
||||||
|
unit := -1
|
||||||
|
for value >= 1024 && unit < len(units)-1 {
|
||||||
|
value /= 1024
|
||||||
|
unit++
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f%s", value, units[unit])
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package executor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.odboy.cn/odboy/kenaito-kopsc/internal/registry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registryVariantBuiltin(name string) registry.Variant {
|
||||||
|
return registry.Variant{Builtin: name, Match: registry.Match{OS: "linux"}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSizedFile(t *testing.T, path string, size int) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
t.Fatalf("mkdir %s: %v", filepath.Dir(path), err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(path, bytes.Repeat([]byte("x"), size), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// fixture builds:
|
||||||
|
//
|
||||||
|
// root/big/big.bin 4096
|
||||||
|
// root/direct.txt 2048
|
||||||
|
// root/small/small.txt 1024
|
||||||
|
// root/empty/ (no files)
|
||||||
|
func diskFixture(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
root := t.TempDir()
|
||||||
|
writeSizedFile(t, filepath.Join(root, "big", "big.bin"), 4096)
|
||||||
|
writeSizedFile(t, filepath.Join(root, "direct.txt"), 2048)
|
||||||
|
writeSizedFile(t, filepath.Join(root, "small", "small.txt"), 1024)
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "empty"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDisk(t *testing.T, args ...string) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := runDiskUsage(context.Background(), BuiltinRequest{Args: args, Stdout: &out})
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageOrdersBySizeDescending(t *testing.T) {
|
||||||
|
root := diskFixture(t)
|
||||||
|
|
||||||
|
out, err := runDisk(t, root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("runDiskUsage error: %v", err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
if len(lines) != 5 {
|
||||||
|
t.Fatalf("expected 4 entries + total, got %d:\n%s", len(lines), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantOrder := []struct {
|
||||||
|
size string
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{"4.0K", "big"},
|
||||||
|
{"2.0K", "direct.txt"},
|
||||||
|
{"1.0K", "small"},
|
||||||
|
{"0", "empty"},
|
||||||
|
}
|
||||||
|
for i, want := range wantOrder {
|
||||||
|
if !strings.HasPrefix(lines[i], want.size) || !strings.Contains(lines[i], want.name) {
|
||||||
|
t.Errorf("line %d = %q, want size %s and name %s", i, lines[i], want.size, want.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(lines[4], "总计 "+root) {
|
||||||
|
t.Errorf("total line = %q, want suffix %q", lines[4], "总计 "+root)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(lines[4], "7.0K") {
|
||||||
|
t.Errorf("total line = %q, want 7.0K", lines[4])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageAcceptsPathFlag(t *testing.T) {
|
||||||
|
root := diskFixture(t)
|
||||||
|
|
||||||
|
for _, args := range [][]string{
|
||||||
|
{"--path", root},
|
||||||
|
{"--path=" + root},
|
||||||
|
{"--path", root, "--"},
|
||||||
|
} {
|
||||||
|
out, err := runDisk(t, args...)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("args %v: %v", args, err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "big") || !strings.Contains(out, "总计") {
|
||||||
|
t.Errorf("args %v produced unexpected output:\n%s", args, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageRequiresPath(t *testing.T) {
|
||||||
|
_, err := runDisk(t)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "必须提供路径") {
|
||||||
|
t.Fatalf("err = %v, want missing-path error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageRejectsBadArgs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{[]string{"--max-depth=2", "/var"}, "未知选项"},
|
||||||
|
{[]string{"-h", "/var"}, "未知选项"},
|
||||||
|
{[]string{"/var", "/tmp"}, "一次只支持一个路径"},
|
||||||
|
{[]string{"--path"}, "--path 缺少参数"},
|
||||||
|
{[]string{"--path="}, "--path 缺少参数"},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
_, err := runDisk(t, tc.args...)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||||
|
t.Errorf("args %v: err = %v, want %q", tc.args, err, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageRejectsFile(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "f.txt")
|
||||||
|
writeSizedFile(t, file, 10)
|
||||||
|
|
||||||
|
_, err := runDisk(t, file)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "不是目录") {
|
||||||
|
t.Fatalf("err = %v, want not-a-directory error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHumanSize(t *testing.T) {
|
||||||
|
cases := map[int64]string{
|
||||||
|
0: "0",
|
||||||
|
512: "512",
|
||||||
|
1023: "1023",
|
||||||
|
1024: "1.0K",
|
||||||
|
1536: "1.5K",
|
||||||
|
1024 * 1024: "1.0M",
|
||||||
|
1024 * 1024 * 1024: "1.0G",
|
||||||
|
1 << 40: "1.0T",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
if got := humanSize(in); got != want {
|
||||||
|
t.Errorf("humanSize(%d) = %q, want %q", in, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuiltinIsRegistered(t *testing.T) {
|
||||||
|
if _, ok := LookupBuiltin("disk.usage"); !ok {
|
||||||
|
t.Fatal("disk.usage builtin not registered")
|
||||||
|
}
|
||||||
|
if _, ok := LookupBuiltin("missing"); ok {
|
||||||
|
t.Fatal("unexpected builtin hit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanRunsBuiltin(t *testing.T) {
|
||||||
|
root := diskFixture(t)
|
||||||
|
plan := samplePlan()
|
||||||
|
plan.Result.Variant = registryVariantBuiltin("disk.usage")
|
||||||
|
plan.ExtraArgs = []string{root}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := plan.Run(context.Background(), Options{Stdout: &out, Stderr: &out}); err != nil {
|
||||||
|
t.Fatalf("Run() error: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "总计") {
|
||||||
|
t.Errorf("builtin output missing total:\n%s", out.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(plan.Display(), "(内置) disk.usage") {
|
||||||
|
t.Errorf("Display() = %q", plan.Display())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanBuiltinDryRunDoesNotExecute(t *testing.T) {
|
||||||
|
plan := samplePlan()
|
||||||
|
plan.Result.Variant = registryVariantBuiltin("disk.usage")
|
||||||
|
plan.ExtraArgs = []string{filepath.Join(t.TempDir(), "missing")}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := plan.Run(context.Background(), Options{DryRun: true, Stdout: &out}); err != nil {
|
||||||
|
t.Fatalf("dry-run should not execute: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "内置实现") {
|
||||||
|
t.Errorf("dry-run output lacks builtin note:\n%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,10 @@ func (p Plan) Argv() []string {
|
|||||||
|
|
||||||
// Display renders the command line the plan would run.
|
// Display renders the command line the plan would run.
|
||||||
func (p Plan) Display() string {
|
func (p Plan) Display() string {
|
||||||
|
if p.Result.Variant.Builtin != "" {
|
||||||
|
parts := append([]string{"(内置)", p.Result.Variant.Builtin}, p.ExtraArgs...)
|
||||||
|
return strings.Join(parts, " ")
|
||||||
|
}
|
||||||
argv := p.Argv()
|
argv := p.Argv()
|
||||||
if p.Result.Variant.Shell {
|
if p.Result.Variant.Shell {
|
||||||
flag := "-c"
|
flag := "-c"
|
||||||
@@ -61,9 +65,12 @@ func (p Plan) Explain(w io.Writer) {
|
|||||||
variant := p.Result.Variant
|
variant := p.Result.Variant
|
||||||
fmt.Fprintf(w, "将执行: %s\n", p.Display())
|
fmt.Fprintf(w, "将执行: %s\n", p.Display())
|
||||||
fmt.Fprintf(w, "平台: %s\n", resolve.DescribeInfo(p.Result.Info))
|
fmt.Fprintf(w, "平台: %s\n", resolve.DescribeInfo(p.Result.Info))
|
||||||
if variant.Shell {
|
switch {
|
||||||
|
case variant.Builtin != "":
|
||||||
|
fmt.Fprintf(w, "方式: 内置实现(%s,不启动外部进程)\n", variant.Builtin)
|
||||||
|
case variant.Shell:
|
||||||
fmt.Fprintf(w, "方式: shell (%s)\n", shellName())
|
fmt.Fprintf(w, "方式: shell (%s)\n", shellName())
|
||||||
} else {
|
default:
|
||||||
fmt.Fprintf(w, "方式: 直接执行\n")
|
fmt.Fprintf(w, "方式: 直接执行\n")
|
||||||
}
|
}
|
||||||
note := variant.Note
|
note := variant.Note
|
||||||
@@ -94,6 +101,14 @@ func (p Plan) Run(ctx context.Context, opts Options) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if variant := p.Result.Variant; variant.Builtin != "" {
|
||||||
|
fn, ok := LookupBuiltin(variant.Builtin)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("未注册的内置动作 %q", variant.Builtin)
|
||||||
|
}
|
||||||
|
return fn(ctx, BuiltinRequest{Args: p.ExtraArgs, Stdout: opts.Stdout, Stderr: opts.Stderr})
|
||||||
|
}
|
||||||
|
|
||||||
name, args := p.command()
|
name, args := p.command()
|
||||||
cmd := exec.CommandContext(ctx, name, args...)
|
cmd := exec.CommandContext(ctx, name, args...)
|
||||||
cmd.Stdin = opts.Stdin
|
cmd.Stdin = opts.Stdin
|
||||||
|
|||||||
@@ -44,6 +44,32 @@ func Builtin() *Registry {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ID: "disk.usage",
|
||||||
|
Aliases: []string{"disk.du"},
|
||||||
|
Group: "disk",
|
||||||
|
Summary: "列出目录下各直接子项的占用空间,按大小降序",
|
||||||
|
Action: "等价于 du -x --max-depth=1 -h <路径> | sort -hr:统计每个直接子项(文件或目录递归)的占用," +
|
||||||
|
"不跨越文件系统边界,末尾输出该路径的合计。路径必填,可作位置参数或用 --path 指定;" +
|
||||||
|
"由于 Windows 没有等价的 du/sort,这里使用 kopsc 内置实现以保证三平台输出一致",
|
||||||
|
Variants: []Variant{
|
||||||
|
{
|
||||||
|
Match: Match{OS: "linux"},
|
||||||
|
Builtin: "disk.usage",
|
||||||
|
Note: "Linux 内置实现(等价 du -x -d 1 -h | sort -hr)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Match: Match{OS: "darwin"},
|
||||||
|
Builtin: "disk.usage",
|
||||||
|
Note: "macOS 内置实现(BSD du 不支持 --max-depth)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Match: Match{OS: "windows"},
|
||||||
|
Builtin: "disk.usage",
|
||||||
|
Note: "Windows 内置实现(系统没有 du/sort)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
ID: "net.ports",
|
ID: "net.ports",
|
||||||
Group: "net",
|
Group: "net",
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ type Variant struct {
|
|||||||
Match Match
|
Match Match
|
||||||
Argv []string // program followed by arguments
|
Argv []string // program followed by arguments
|
||||||
Shell bool // run through the platform shell instead of exec'ing Argv[0]
|
Shell bool // run through the platform shell instead of exec'ing Argv[0]
|
||||||
|
// Builtin names an in-process implementation registered with the executor.
|
||||||
|
// It is mutually exclusive with Argv and lets kopsc provide commands that
|
||||||
|
// have no equivalent system utility (for example on Windows).
|
||||||
|
Builtin string
|
||||||
Note string // human-readable rationale shown by `kopsc explain`
|
Note string // human-readable rationale shown by `kopsc explain`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +77,13 @@ func (r *Registry) Validate() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for j, variant := range cmd.Variants {
|
for j, variant := range cmd.Variants {
|
||||||
if len(variant.Argv) == 0 || strings.TrimSpace(variant.Argv[0]) == "" {
|
if len(variant.Argv) == 0 && variant.Builtin == "" {
|
||||||
|
return fmt.Errorf("command %q variant #%d: empty argv and no builtin", cmd.ID, j)
|
||||||
|
}
|
||||||
|
if len(variant.Argv) > 0 && variant.Builtin != "" {
|
||||||
|
return fmt.Errorf("command %q variant #%d: argv and builtin are mutually exclusive", cmd.ID, j)
|
||||||
|
}
|
||||||
|
if len(variant.Argv) > 0 && strings.TrimSpace(variant.Argv[0]) == "" {
|
||||||
return fmt.Errorf("command %q variant #%d: empty argv", cmd.ID, j)
|
return fmt.Errorf("command %q variant #%d: empty argv", cmd.ID, j)
|
||||||
}
|
}
|
||||||
if err := variant.Match.validate(); err != nil {
|
if err := variant.Match.validate(); err != nil {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ func TestBuiltinIsValid(t *testing.T) {
|
|||||||
|
|
||||||
func TestBuiltinIDsStable(t *testing.T) {
|
func TestBuiltinIDsStable(t *testing.T) {
|
||||||
reg := Builtin()
|
reg := Builtin()
|
||||||
want := []string{"docker.clearsafe", "net.ports", "pkg.refreshindex"}
|
want := []string{"docker.clearsafe", "disk.usage", "net.ports", "pkg.refreshindex"}
|
||||||
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
|
if got := reg.IDs(); !reflect.DeepEqual(got, want) {
|
||||||
t.Fatalf("IDs() = %v, want %v", got, want)
|
t.Fatalf("IDs() = %v, want %v", got, want)
|
||||||
}
|
}
|
||||||
@@ -132,6 +132,45 @@ func TestValidateErrors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateBuiltinVariants(t *testing.T) {
|
||||||
|
builtinOnly := plainCommand("a")
|
||||||
|
builtinOnly.Variants = []Variant{{Match: Match{OS: "linux"}, Builtin: "demo.action"}}
|
||||||
|
|
||||||
|
if err := (&Registry{Commands: []Command{builtinOnly}}).Validate(); err != nil {
|
||||||
|
t.Fatalf("builtin-only variant should be valid: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
both := plainCommand("a")
|
||||||
|
both.Variants = []Variant{{Match: Match{OS: "linux"}, Argv: []string{"x"}, Builtin: "demo.action"}}
|
||||||
|
err := (&Registry{Commands: []Command{both}}).Validate()
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||||
|
t.Fatalf("err = %v, want mutual-exclusion error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiskUsageIsBuiltinEverywhere(t *testing.T) {
|
||||||
|
reg := Builtin()
|
||||||
|
cmd, ok := reg.Lookup("disk.usage")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("disk.usage not found")
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, variant := range cmd.Variants {
|
||||||
|
if variant.Builtin != "disk.usage" {
|
||||||
|
t.Errorf("variant for %s should use the disk.usage builtin, got %q", variant.Match.OS, variant.Builtin)
|
||||||
|
}
|
||||||
|
if len(variant.Argv) != 0 {
|
||||||
|
t.Errorf("builtin variant must not carry argv: %v", variant.Argv)
|
||||||
|
}
|
||||||
|
seen[variant.Match.OS] = true
|
||||||
|
}
|
||||||
|
for _, osName := range []string{"linux", "darwin", "windows"} {
|
||||||
|
if !seen[osName] {
|
||||||
|
t.Errorf("disk.usage lacks a %s variant", osName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchIsComparable(t *testing.T) {
|
func TestMatchIsComparable(t *testing.T) {
|
||||||
a := Match{OS: "linux", Distro: "centos", DistroLike: []string{"rhel"}, Runtime: "docker"}
|
a := Match{OS: "linux", Distro: "centos", DistroLike: []string{"rhel"}, Runtime: "docker"}
|
||||||
b := Match{OS: "linux", Distro: "centos", DistroLike: []string{"rhel"}, Runtime: "docker"}
|
b := Match{OS: "linux", Distro: "centos", DistroLike: []string{"rhel"}, Runtime: "docker"}
|
||||||
|
|||||||
Reference in New Issue
Block a user